diff --git a/application/controllers/jobs/AbgabetoolJob.php b/application/controllers/jobs/AbgabetoolJob.php
index 2e5c4d7b9..5db78b7ec 100644
--- a/application/controllers/jobs/AbgabetoolJob.php
+++ b/application/controllers/jobs/AbgabetoolJob.php
@@ -290,6 +290,38 @@ class AbgabetoolJob extends JOB_Controller
}
}
+ /**
+ * helper to tell apart an actually new/edited abgabetermin from an upload or a benotung, since all
+ * three write updatevon/updateamum on the same paabgabe row and the row itself keeps no change history.
+ * uploads and benotungen have their own notifications, reporting them as changed termine is what made
+ * betreuer/assistenz receive "Termine hinzugefügt oder bearbeitet" after a student uploaded a file.
+ */
+ private function isNewOrEditedTermin($abgabe) {
+ // never updated, so the termin itself was created within the interval
+ if (empty($abgabe->updatevon)) {
+ return true;
+ }
+
+ // the student can only ever write abgabedatum by uploading -> notifyBetreuerMail/notifyStudentMail report those
+ if ($abgabe->student_uid === $abgabe->updatevon) {
+ return false;
+ }
+
+ // upload done by assistenz/betreuer on behalf of the student: abgabedatum is written on the day of the update
+ if (!empty($abgabe->abgabedatum) && !empty($abgabe->updateamum)
+ && substr($abgabe->abgabedatum, 0, 10) === substr($abgabe->updateamum, 0, 10)) {
+ return false;
+ }
+
+ // typ and datum of a benoteter termin are locked (see Abgabe->postProjektarbeitAbgabe), so an update
+ // on it is either the benotung itself or a cosmetic edit - neither is a termin change
+ if ($abgabe->note !== null) {
+ return false;
+ }
+
+ return true;
+ }
+
public function notifyAssistenzAboutChangedAbgaben() {
$this->_ci->logInfo('Start job FHC-Core->notifyAssistenzAboutChangedAbgaben');
@@ -298,7 +330,7 @@ class AbgabetoolJob extends JOB_Controller
$relevantTypes = $this->_ci->config->item('RELEVANT_PAABGABETYPEN_SAMMELMAIL_ASSISTENZ');
// get all new or changed termine in interval
$result = $this->_ci->PaabgabeModel->findAbgabenNewOrUpdatedSince($interval, $relevantTypes);
-
+
$retval = getData($result);
if(count($retval) == 0) {
@@ -314,7 +346,12 @@ class AbgabetoolJob extends JOB_Controller
if (isset($newOrChangedAbgabe->projektarbeit_id)) {
$projektarbeitId = $newOrChangedAbgabe->projektarbeit_id;
- // If the 'projektarbeit_id' is not yet a key in $projektarbeiten,
+ // uploads and benotungen are no termin changes, they have their own notifications
+ if (!$this->isNewOrEditedTermin($newOrChangedAbgabe)) {
+ continue;
+ }
+
+ // If the 'projektarbeit_id' is not yet a key in $projektarbeiten,
// initialize it as an empty array.
if (!isset($projektarbeiten[$projektarbeitId])) {
$projektarbeiten[$projektarbeitId] = [];
@@ -325,6 +362,11 @@ class AbgabetoolJob extends JOB_Controller
}
}
+ if(count($projektarbeiten) == 0) {
+ $this->_ci->logInfo("Keine Emails an Assistenzen über neue oder veränderte Termine versandt");
+ return;
+ }
+
// for each projektarbeit fetch their assistenz and same them in their own dictionary to avoid too many mails
$assistenzMap = [];
// for each projektarbeit fetch their betreuer and save them in their own dictionary to avoid too many mails
@@ -515,14 +557,12 @@ class AbgabetoolJob extends JOB_Controller
if (isset($newOrChangedAbgabe->projektarbeit_id)) {
$projektarbeitId = $newOrChangedAbgabe->projektarbeit_id;
- // check if the updatevon field is NOT the same as the student the projektarbeit is assigned to
- // since uploading a file to a paabgabe is also putting updateamum & updatevon
- // we have our own "student has uploaded a file" emailjob anyways
- if($newOrChangedAbgabe->student_uid === $newOrChangedAbgabe->updatevon) {
+ // uploads and benotungen are no termin changes, they have their own notifications
+ if (!$this->isNewOrEditedTermin($newOrChangedAbgabe)) {
continue;
}
-
- // If the 'projektarbeit_id' is not yet a key in $projektarbeiten,
+
+ // If the 'projektarbeit_id' is not yet a key in $projektarbeiten,
// initialize it as an empty array.
if (!isset($projektarbeiten[$projektarbeitId])) {
$projektarbeiten[$projektarbeitId] = [];
@@ -720,61 +760,112 @@ class AbgabetoolJob extends JOB_Controller
forEach ($betreuer_uids as $person_id => $abgaben) {
// $person_id is from betreuer
- $result = $this->_ci->ProjektarbeitModel->getProjektbetreuerAnrede($person_id);
- $data = getData($result)[0];
+ // resolve the recipient by the person_id the mail was grouped by. resolving it by
+ // projektarbeit_id + betreuerart of the first abgabe addressed the wrong person as soon as a
+ // projektarbeit had two betreuer of the same art
+ $result = $this->_ci->ProjektbetreuerModel->getBetreuerByPersonId($person_id);
+ $betreuerArr = getData($result);
- $anrede = $data->anrede;
- $anredeFillString = $data->anrede == "Herr" ? "r" : "";
- $fullFormattedNameString = $data->first;
-
- // sorting $abgaben array by datum
- usort($abgaben, function ($a, $b) {
- return strtotime($a->datum) <=> strtotime($b->datum);
- });
-
- $projektarbeit_titel = $abgaben[0]->titel;
-
- // initialize the table and headers
- $abgabenString = '
-
-
-
- | Zieldatum |
- Studierende/r |
- Bezeichnung |
- Abgabedatum |
-
-
- ';
-
- foreach ($abgaben as $abgabe) {
- // format the student name
- $nameParts = [];
- if (!empty($abgabe->titelpre)) $nameParts[] = $abgabe->titelpre;
- $nameParts[] = $abgabe->vorname;
- $nameParts[] = $abgabe->nachname;
- if (!empty($abgabe->titelpost)) $nameParts[] = $abgabe->titelpost;
- $studentFullName = implode(' ', $nameParts);
-
- // format dates inline
- $dateEmailFormatted = (new DateTime($abgabe->datum))->format('d.m.Y');
- $abgabedatumFormatted = (new DateTime($abgabe->abgabedatum))->format('d.m.Y');
-
- // handle the optional Kurzbezeichnung
- $kurzbzLine = !empty($abgabe->kurzbz) ? "
{$abgabe->kurzbz}" : "";
-
- $abgabenString .= "
-
- | {$dateEmailFormatted} |
- {$studentFullName} |
-
- {$abgabe->bezeichnung}{$kurzbzLine}
- |
- {$abgabedatumFormatted} |
-
";
+ if(!$betreuerArr) {
+ $this->_ci->logInfo('Could not load Betreuer PersonID: "'.$person_id.'".');
+ continue;
}
- $abgabenString .= '
';
+ $betreuer = $betreuerArr[0];
+
+ $email = $betreuer->uid ? $betreuer->uid."@".DOMAIN : $betreuer->private_email;
+
+ // in rare cases there are betreuer (often zweitbetreuer) without uid and without private email
+ if(!$email) {
+ $this->_ci->logInfo('Could not send Email for Betreuer PersonID: "'.$person_id.'".');
+ continue;
+ }
+
+ $anrede = $betreuer->anrede;
+ $anredeFillString = $betreuer->anrede == "Herr" ? "r" : "";
+ $fullFormattedNameString = $betreuer->first;
+
+ // a betreuer can supervise several projektarbeiten, so group the uploads and give each one its
+ // own section instead of printing one projektarbeit title above uploads of all of them
+ $abgabenProProjektarbeit = [];
+ forEach($abgaben as $abgabe) {
+ $abgabenProProjektarbeit[$abgabe->projektarbeit_id][] = $abgabe;
+ }
+
+ // start the container
+ $abgabenString = '';
+
+ forEach($abgabenProProjektarbeit as $projektarbeit_id => $projektarbeitAbgaben) {
+
+ // sorting $projektarbeitAbgaben array by datum
+ usort($projektarbeitAbgaben, function ($a, $b) {
+ return strtotime($a->datum) <=> strtotime($b->datum);
+ });
+
+ // format the student name
+ $s = $projektarbeitAbgaben[0];
+ $nameParts = [];
+ if (!empty($s->titelpre)) $nameParts[] = $s->titelpre;
+ $nameParts[] = $s->vorname;
+ $nameParts[] = $s->nachname;
+ if (!empty($s->titelpost)) $nameParts[] = $s->titelpost;
+ $studentFullName = implode(' ', $nameParts);
+
+ $projektarbeit_titel = $s->titel ?? 'Kein Titel vergeben';
+
+ // project header section
+ $abgabenString .= "
+
+
Projekt: {$projektarbeit_titel}
+
+ Studierende/r: {$studentFullName}
+
+
+ ID: {$projektarbeit_id} | Rolle: {$s->betreuerart_kurzbz} |
+ Stg: {$s->stgtyp}{$s->stgkz} ({$s->studiensemester_kurzbz})
+
+
";
+
+ // start table
+ $abgabenString .= '
+
+
+
+ | Zieldatum |
+ Bezeichnung |
+ Abgabedatum |
+
+
+ ';
+
+ foreach ($projektarbeitAbgaben as $abgabe) {
+ // format dates inline
+ $dateEmailFormatted = (new DateTime($abgabe->datum))->format('d.m.Y');
+ $abgabedatumFormatted = (new DateTime($abgabe->abgabedatum))->format('d.m.Y');
+
+ // handle the optional Kurzbezeichnung
+ $kurzbzLine = !empty($abgabe->kurzbz) ? "
{$abgabe->kurzbz}" : "";
+
+ $abgabenString .= "
+
+ | {$dateEmailFormatted} |
+
+ {$abgabe->bezeichnung}{$kurzbzLine}
+ |
+ {$abgabedatumFormatted} |
+
";
+ }
+
+ $abgabenString .= '
';
+ }
+
+ // close container
+ $abgabenString .= '
';
+
+ // the template prints one projektarbeit title, which only holds when the mail covers a single one
+ $paTitel = count($abgabenProProjektarbeit) === 1
+ ? ($abgaben[0]->titel ?? 'Kein Titel vergeben')
+ : $this->p->t('abgabetool', 'c4mehrereProjektarbeiten');
$path = $this->_ci->config->item('URL_MITARBEITER');
$url = CIS_ROOT.$path;
@@ -783,28 +874,17 @@ class AbgabetoolJob extends JOB_Controller
'anrede' => $anrede,
'anredeFillString' => $anredeFillString,
'fullFormattedNameString' => $fullFormattedNameString,
- 'paTitel' => $projektarbeit_titel,
+ 'paTitel' => $paTitel,
'abgabenString' => $abgabenString,
'linkAbgabetool' => $url
);
- $result = $this->_ci->ProjektbetreuerModel->getBetreuerOfProjektarbeit($abgaben[0]->projektarbeit_id, $abgaben[0]->betreuerart_kurzbz);
- $data = getData($result)[0];
-
- $email = $data->uid ? $data->uid."@".DOMAIN : $data->private_email;
-
- // in rare cases there are betreuer (often zweitbetreuer) without uid and without private email
- if(!$email) {
- $this->_ci->logInfo('Could not send Email for Betreuer PersonID: "'.$data->person_id.'".');
- continue;
- }
-
// send email with bundled info
sendSanchoMail(
'PaabgabeUpdatesBetSM',
$body_fields,
$email,
- $this->p->t('abgabetool', 'changedAbgabeterminev2')
+ $this->p->t('abgabetool', 'c4newUploadsNotification')
);
$count++;
diff --git a/application/models/education/Paabgabe_model.php b/application/models/education/Paabgabe_model.php
index 99b9b75f1..c12818a94 100644
--- a/application/models/education/Paabgabe_model.php
+++ b/application/models/education/Paabgabe_model.php
@@ -66,7 +66,8 @@ class Paabgabe_model extends DB_Model
$query = "SELECT projektarbeit_id, paabgabe_id, paabgabetyp_kurzbz, fixtermin, datum, campus.tbl_paabgabe.kurzbz, campus.tbl_paabgabetyp.bezeichnung, campus.tbl_paabgabe.abgabedatum,
campus.tbl_paabgabe.insertvon, campus.tbl_paabgabe.insertamum, campus.tbl_paabgabe.updatevon, campus.tbl_paabgabe.updateamum,
- campus.tbl_paabgabe.note, upload_allowed, beurteilungsnotiz, student_uid, tbl_projektarbeit.note, lehre.tbl_projektarbeit.titel,
+ campus.tbl_paabgabe.note, upload_allowed, beurteilungsnotiz, student_uid,
+ lehre.tbl_projektarbeit.note as projektarbeit_note, lehre.tbl_projektarbeit.titel,
UPPER(tbl_studiengang.typ) as stgtyp, UPPER(tbl_studiengang.kurzbz) as stgkz, public.tbl_studiengang.studiengang_kz,
public.tbl_studiengang.oe_kurzbz as stg_oe_kurzbz, tbl_lehreinheit.studiensemester_kurzbz,
public.tbl_person.anrede, public.tbl_person.titelpre, public.tbl_person.vorname, public.tbl_person.nachname, public.tbl_person.titelpost
@@ -91,7 +92,8 @@ class Paabgabe_model extends DB_Model
$queryParams = [$interval];
$query = "SELECT projektarbeit_id, paabgabe_id, paabgabetyp_kurzbz, fixtermin, datum, campus.tbl_paabgabe.kurzbz, campus.tbl_paabgabetyp.bezeichnung, campus.tbl_paabgabe.abgabedatum,
campus.tbl_paabgabe.insertvon, campus.tbl_paabgabe.insertamum, campus.tbl_paabgabe.updatevon, campus.tbl_paabgabe.updateamum,
- campus.tbl_paabgabe.note, upload_allowed, beurteilungsnotiz, student_uid, tbl_projektarbeit.note, lehre.tbl_projektarbeit.titel,
+ campus.tbl_paabgabe.note, upload_allowed, beurteilungsnotiz, student_uid,
+ lehre.tbl_projektarbeit.note as projektarbeit_note, lehre.tbl_projektarbeit.titel,
UPPER(tbl_studiengang.typ) as stgtyp, UPPER(tbl_studiengang.kurzbz) as stgkz, public.tbl_studiengang.studiengang_kz,
public.tbl_studiengang.oe_kurzbz as stg_oe_kurzbz, tbl_lehreinheit.studiensemester_kurzbz,
lehre.tbl_projektbetreuer.betreuerart_kurzbz, lehre.tbl_projektbetreuer.person_id,
diff --git a/application/models/education/Projektbetreuer_model.php b/application/models/education/Projektbetreuer_model.php
index 47e0239d6..519175c06 100644
--- a/application/models/education/Projektbetreuer_model.php
+++ b/application/models/education/Projektbetreuer_model.php
@@ -269,4 +269,37 @@ class Projektbetreuer_model extends DB_Model
return $this->execQuery($qry, array($projektarbeit_id));
}
+ /**
+ * Gets anrede, name and mail address of a Betreuer by person_id.
+ * Same account preference as getAllBetreuerOfProjektarbeit, but not bound to a single Projektarbeit.
+ * @param int $person_id
+ * @return array success with one row or error
+ */
+ public function getBetreuerByPersonId($person_id)
+ {
+ $qry = "SELECT DISTINCT ON (pers.person_id) pers.person_id, anrede,
+ trim(COALESCE(titelpre,'')||' '||COALESCE(vorname,'')||' '||COALESCE(nachname,'')||' '||COALESCE(titelpost,'')) as first,
+ ben.uid,
+ (
+ SELECT kontakt
+ FROM public.tbl_kontakt
+ WHERE kontakttyp = 'email'
+ AND person_id = pers.person_id
+ ORDER BY
+ CASE WHEN zustellung THEN 0 ELSE 1 END,
+ insertamum DESC NULLS LAST
+ LIMIT 1
+ ) AS private_email
+ FROM public.tbl_person pers
+ LEFT JOIN public.tbl_benutzer ben USING (person_id)
+ LEFT JOIN public.tbl_mitarbeiter ma ON ben.uid = ma.mitarbeiter_uid
+ WHERE (ben.aktiv OR ben.aktiv IS NULL)
+ AND pers.person_id = ?
+ ORDER BY pers.person_id, CASE WHEN ma.mitarbeiter_uid IS NULL THEN 1 ELSE 0 END, /*Mitarbeiter account first*/
+ CASE WHEN ben.uid IS NULL THEN 1 ELSE 0 END, /*user with account first*/
+ ben.insertamum";
+
+ return $this->execReadOnlyQuery($qry, array($person_id));
+ }
+
}
diff --git a/public/js/components/Cis/Abgabetool/AbgabetoolAssistenz.js b/public/js/components/Cis/Abgabetool/AbgabetoolAssistenz.js
index 3d8fc1d79..9640310a5 100644
--- a/public/js/components/Cis/Abgabetool/AbgabetoolAssistenz.js
+++ b/public/js/components/Cis/Abgabetool/AbgabetoolAssistenz.js
@@ -11,10 +11,12 @@ import AbgabeStudentTimeline from "./AbgabeStudentTimeline.js";
import { splitMailsHelper } from "../../../helpers/EmailHelpers.js"
import { getDateStyleClass} from "./getDateStyleClass.js";
import { dateFilter } from '../../../tabulator/filters/DatesManual.js';
-import { compareISODateValues, formatISODate, getViennaTodayISO, toViennaDate } from "./dateUtils.js";
+import { compareISODateValues, getViennaTodayISO, toViennaDate } from "./dateUtils.js";
+import AbgabetoolTableMixin from "./AbgabetoolTableMixin.js";
export const AbgabetoolAssistenz = {
name: "AbgabetoolAssistenz",
+ mixins: [AbgabetoolTableMixin],
components: {
AbgabeterminStatusLegende,
AbgabeStudentTimeline,
@@ -50,10 +52,6 @@ export const AbgabetoolAssistenz = {
return {
flatDataDirty: true,
mode: 'perProjectView',
- qgate1FilterSelected: [],
- qgate2FilterSelected: [],
- pa_noteFilterSelected: [],
- noteFilterSelected: [],
count: 0,
filteredcount: 0,
selectedcount: 0,
@@ -68,16 +66,9 @@ export const AbgabetoolAssistenz = {
curSem: null,
notenOptionFilter: null,
inplaceToggle: false,
- headerFiltersRestored: false,
- filtersRestored: false,
- colLayoutRestored: false,
- sortRestored: false,
- stateRestored: false,
- headerFiltersRestoredFlat: false,
- filtersRestoredFlat: false,
- colLayoutRestoredFlat: false,
- sortRestoredFlat: false,
- stateRestoredFlat: false,
+ // restore flags of the two tables, filled by initTablePersistence
+ tableState: {},
+ tableStateFlat: {},
timelineProjekte: [],
selectedStudiengangOption: null,
studiengaengeOptions: null,
@@ -211,7 +202,12 @@ export const AbgabetoolAssistenz = {
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4orgformv2'))), field: 'orgform', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 50, visible: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4projekttyp'))), field: 'projekttyp_kurzbz', formatter: this.centeredTextFormatter, minWidth: 150, visible: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4stg'))), field: 'stg', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 50, visible: false},
- {title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4note'))), field: 'note_bez', headerFilter: true, sorter: this.notenSorter, visible: false, minWidth: 200, formatter: this.centeredTextFormatter},
+ {title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4note'))), field: 'note_bez',
+ headerFilter: this.notenHeaderFilterEditor,
+ headerFilterFunc: this.notenHeaderFilterFunc,
+ headerFilterParams: {},
+ headerFilterFuncParams: { idField: 'note' }, // column shows the bezeichnung, filter compares the note id
+ sorter: this.notenSorter, visible: false, minWidth: 200, formatter: this.centeredTextFormatter},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4sem'))), field: 'studiensemester_kurzbz', headerFilter: true, visible: false, formatter: this.centeredTextFormatter, minWidth: 100},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4titel'))), field: 'titel', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 100, visible: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4erstbetreuerv2'))), field: 'erstbetreuer', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 100, visible: false},
@@ -530,104 +526,8 @@ export const AbgabetoolAssistenz = {
const bData = bRow.getData()
return aData.pa_note - bData.pa_note
},
- notenSorter(a, b, aRow, bRow, column, dir, sorterParams) {
- const aData = aRow.getData()
- const bData = bRow.getData()
- return aData.note - bData.note
- },
- notenHeaderFilterEditor(cell, onRendered, success, cancel, editorParams) {
- if (!this.notenOptions) return;
-
- const field = cell.getField();
- const stateKey = field + 'FilterSelected';
- let selected = [...(this[stateKey] || [])];
-
- const wrapper = document.createElement('div');
- wrapper.style.cssText = 'position: relative; width: 100%;';
-
- const display = document.createElement('input');
- display.readOnly = true;
- display.placeholder = '';
- display.style.cssText = 'padding: 4px; width: 100%; box-sizing: border-box; cursor: default; border: 1px solid; outline: none; background: #fff; appearance: none; caret-color: transparent;';
-
- const dropdown = document.createElement('div');
- dropdown.style.cssText = 'display: none; position: fixed; background: #fff; border: 1px solid; z-index: 9999; min-width: 180px; box-shadow: 0 2px 6px rgba(0,0,0,0.15);';
-
-
- // mapping evaluated at render time, not at column definition time
- const fieldOptionsMap = {
- 'pa_note': this.notenOptions,
- 'note': this.allowedNotenOptions,
- };
- const options = fieldOptionsMap[cell.getField()] ?? this.notenOptions;
- if (!options) return;
-
- const updateDisplay = () => {
- display.value = options
- .filter(o => selected.includes(o.note))
- .map(o => o.bezeichnung)
- .join(', ');
- };
- options.forEach(opt => {
- const row = document.createElement('label');
- row.style.cssText = 'display: flex; align-items: center; gap: 6px; padding: 4px 8px; cursor: pointer; white-space: nowrap;';
- row.addEventListener('mousedown', e => e.preventDefault());
-
- const cb = document.createElement('input');
- cb.type = 'checkbox';
- cb.value = opt.note;
- cb.checked = selected.includes(opt.note);
- cb.style.cssText = 'margin: 0 6px;';
- cb.addEventListener('change', () => {
- selected = cb.checked
- ? [...selected, opt.note]
- : selected.filter(v => v !== opt.note);
- this[stateKey] = [...selected];
- updateDisplay();
- success([...selected]);
- });
-
- const labelText = document.createElement('span');
- labelText.textContent = opt.bezeichnung;
-
- row.appendChild(cb);
- row.appendChild(labelText);
- dropdown.appendChild(row);
- });
-
- updateDisplay();
-
- display.addEventListener('click', () => {
- if (dropdown.style.display === 'none') {
- const rect = display.getBoundingClientRect();
- dropdown.style.top = rect.bottom + 'px';
- dropdown.style.left = rect.left + 'px';
- dropdown.style.display = 'block';
- } else {
- dropdown.style.display = 'none';
- }
- });
-
- display.addEventListener('blur', () => {
- setTimeout(() => { dropdown.style.display = 'none'; }, 150);
- });
-
- document.body.appendChild(dropdown);
- wrapper.appendChild(display);
- cell.getElement().addEventListener('remove', () => dropdown.remove());
- onRendered(() => display.focus());
-
- return wrapper;
- },
-
- notenHeaderFilterFunc(filterVal, rowVal, rowData, filterParams) {
- if (!filterVal || !filterVal.length) return true;
- // rowVal is the raw integer note id or a note object
- const noteId = typeof rowVal === 'object' ? rowVal?.note : rowVal;
- return filterVal.some(val => val == noteId); // loose equality: filter vals are numbers, noteId might be string
- },
handleFilterActiveChanged(active) {
- if(!active && this.allSemOption && this.stateRestored) {
+ if(!active && this.allSemOption && this.tableState.stateRestored) {
this.curSem = this.allSemOption
}
},
@@ -779,213 +679,6 @@ export const AbgabetoolAssistenz = {
this.mode = 'perProjectView'
}
},
- getDateStyleHtml(dateStyle) {
- const iconMap = {
- 'verspaetet': '',
- 'verpasst': '',
- 'abzugeben': '',
- 'standard': '',
- 'abgegeben': '',
- 'beurteilungerforderlich': '',
- 'bestanden': '',
- 'nichtbestanden': '',
- };
- return iconMap[dateStyle] ?? '';
- },
- statusHeaderFilterEditor(cell, onRendered, success, cancel, editorParams) {
- const options = [
- { label: this.$p.t('abgabetool/c4positivBenotet'), value: 'bestanden', dateStyle: 'bestanden' },
- { label: this.$p.t('abgabetool/c4negativBenotet'), value: 'nichtbestanden', dateStyle: 'nichtbestanden' },
- { label: this.$p.t('abgabetool/c4tooltipVerspaetet'), value: 'verspaetet', dateStyle: 'verspaetet' },
- { label: this.$p.t('abgabetool/c4tooltipVerpasst'), value: 'verpasst', dateStyle: 'verpasst' },
- { label: this.$p.t('abgabetool/c4tooltipAbzugeben'), value: 'abzugeben', dateStyle: 'abzugeben' },
- { label: this.$p.t('abgabetool/c4tooltipAbgegeben'), value: 'abgegeben', dateStyle: 'abgegeben' },
- { label: this.$p.t('abgabetool/c4tooltipBeurteilungerforderlich'), value: 'beurteilungerforderlich', dateStyle: 'beurteilungerforderlich' },
- { label: this.$p.t('abgabetool/c4tooltipStandardv2'), value: 'standard', dateStyle: 'standard' },
- ];
-
- const field = cell.getField();
- const stateKey = field + 'FilterSelected'; // e.g. dateStyleFilterSelected
- let selected = [...(this[stateKey] || [])];
-
- const wrapper = document.createElement('div');
- wrapper.style.cssText = 'position: relative; width: 100%;';
-
- const display = document.createElement('input');
- display.readOnly = true;
- display.placeholder = '';
- display.style.cssText = 'padding: 4px; width: 100%; box-sizing: border-box; cursor: default; border: 1px solid; outline: none; background: #fff; appearance: none; caret-color: transparent;';
-
- const dropdown = document.createElement('div');
- dropdown.style.cssText = 'display: none; position: fixed; background: #fff; border: 1px solid; z-index: 9999; min-width: 220px; box-shadow: 0 2px 6px rgba(0,0,0,0.15);';
-
- const updateDisplay = () => {
- display.value = options
- .filter(o => selected.includes(o.value))
- .map(o => o.label)
- .join(', ');
- };
-
- options.forEach(opt => {
- const row = document.createElement('label');
- row.style.cssText = 'display: flex; align-items: center; gap: 0; cursor: pointer; white-space: nowrap; padding-right: 8px;';
- row.addEventListener('mousedown', e => e.preventDefault());
-
- const cb = document.createElement('input');
- cb.type = 'checkbox';
- cb.value = opt.value;
- cb.checked = selected.includes(opt.value);
- cb.style.cssText = 'margin: 0 6px;';
- cb.addEventListener('change', () => {
- selected = cb.checked
- ? [...selected, opt.value]
- : selected.filter(v => v !== opt.value);
- this[stateKey] = [...selected];
- updateDisplay();
- success([...selected]);
- });
-
- // icon badge — same look as cell
- const badge = document.createElement('div');
- badge.className = opt.dateStyle + '-header';
- badge.style.cssText = `min-width: 36px; height: 36px; display: flex; align-items: center;
- justify-content: center; flex-shrink: 0; padding: 0px 17px 0px 17px;`;
- badge.innerHTML = this.getDateStyleHtml(opt.dateStyle);
-
- const labelText = document.createElement('span');
- labelText.textContent = opt.label;
- labelText.style.cssText = 'margin-left: 6px;';
-
- row.appendChild(cb);
- row.appendChild(badge);
- row.appendChild(labelText);
- dropdown.appendChild(row);
- });
-
- updateDisplay();
-
- display.addEventListener('click', () => {
- if (dropdown.style.display === 'none') {
- const rect = display.getBoundingClientRect();
- dropdown.style.top = rect.bottom + 'px';
- dropdown.style.left = rect.left + 'px';
- dropdown.style.display = 'block';
- } else {
- dropdown.style.display = 'none';
- }
- });
-
- display.addEventListener('blur', () => {
- setTimeout(() => { dropdown.style.display = 'none'; }, 150);
- });
-
- document.body.appendChild(dropdown);
- wrapper.appendChild(display);
- cell.getElement().addEventListener('remove', () => dropdown.remove());
- onRendered(() => display.focus());
-
- return wrapper;
- },
- statusHeaderFilterFunc(filterVal, rowVal, rowData, filterParams) {
- if (!filterVal || !filterVal.length) return true;
- // rowVal is the raw dateStyle string on the flat table
- return filterVal.some(val => val === rowVal);
- },
- qgateHeaderFilterEditor(cell, onRendered, success, cancel, editorParams) {
-
- const options = [
- { label: '[+] ' + this.$p.t('abgabetool/c4positivBenotet'), value: 'positive' },
- { label: '[-] ' + this.$p.t('abgabetool/c4negativBenotet'), value: 'negative' },
- { label: '[~] ' + this.$p.t('abgabetool/c4notYetGraded'), value: 'not_graded' },
- { label: '[?] ' + this.$p.t('abgabetool/c4notSubmitted'), value: 'not_submitted' },
- { label: '[o] ' + this.$p.t('abgabetool/c4notHappenedYet'), value: 'not_happened' },
- { label: '[--] ' + this.$p.t('abgabetool/c4keinTerminVorhanden'), value: 'no_termin' },
- ];
-
- const field = cell.getField();
- const stateKey = field === 'qgate1Status' ? 'qgate1FilterSelected' : 'qgate2FilterSelected';
- let selected = [...(this[stateKey] || [])]; // restore persistence state
-
- const wrapper = document.createElement('div');
- wrapper.style.cssText = 'position: relative; width: 100%;';
-
- const display = document.createElement('input');
- display.readOnly = true;
- display.placeholder = '';
- display.style.cssText = 'padding: 4px; width: 100%; box-sizing: border-box; cursor: default; border: 1px solid; outline: none; background: #fff; appearance: none; caret-color: transparent;';
-
- const dropdown = document.createElement('div');
- dropdown.style.cssText = 'display: none; position: fixed; background: #fff; border: 1px solid; z-index: 9999; min-width: 180px; box-shadow: 0 2px 6px rgba(0,0,0,0.15);';
-
- options.forEach(opt => {
- const row = document.createElement('label');
- row.style.cssText = 'display: flex; align-items: center; gap: 6px; padding: 4px 8px; cursor: pointer; white-space: nowrap;';
- row.addEventListener('mousedown', e => e.preventDefault());
-
- const cb = document.createElement('input');
- cb.type = 'checkbox';
- cb.value = opt.value;
- cb.checked = selected.includes(opt.value); // sync with persistence
- cb.addEventListener('change', () => {
- if (cb.checked) {
- selected.push(opt.value);
- } else {
- selected = selected.filter(v => v !== opt.value);
- }
- this[stateKey] = [...selected]; // sync with persistence
- display.value = options.filter(o => selected.includes(o.value)).map(o => o.label).join(', ');
- success([...selected]);
- });
-
- row.appendChild(cb);
- row.appendChild(document.createTextNode(opt.label));
- dropdown.appendChild(row);
- });
-
- display.value = options.filter(o => selected.includes(o.value)).map(o => o.label).join(', ');
-
- display.addEventListener('click', () => {
- if (dropdown.style.display === 'none') {
- const rect = display.getBoundingClientRect();
- dropdown.style.top = rect.bottom + 'px';
- dropdown.style.left = rect.left + 'px';
- dropdown.style.display = 'block';
- } else {
- dropdown.style.display = 'none';
- }
- });
-
- display.addEventListener('blur', () => {
- setTimeout(() => { dropdown.style.display = 'none'; }, 150);
- });
-
- document.body.appendChild(dropdown);
- wrapper.appendChild(display);
-
- cell.getElement().addEventListener('remove', () => dropdown.remove());
-
- onRendered(() => display.focus());
-
- return wrapper;
- },
- qgateHeaderFilterFunc(filterVal, rowVal, rowData, filterParams) {
- if (!filterVal || !filterVal.length) return true;
-
- const matches = (val) => {
- switch (val) {
- case 'positive': return rowVal === this.$p.t('abgabetool/c4positivBenotet');
- case 'negative': return rowVal === this.$p.t('abgabetool/c4negativBenotet');
- case 'not_graded': return rowVal === this.$p.t('abgabetool/c4notYetGraded');
- case 'not_submitted':return rowVal === this.$p.t('abgabetool/c4notSubmitted');
- case 'not_happened': return rowVal === this.$p.t('abgabetool/c4notHappenedYet');
- case 'no_termin': return rowVal === this.$p.t('abgabetool/c4keinTerminVorhanden');
- default: return true;
- }
- };
-
- // OR logic — row passes if it matches any selected filter
- return filterVal.some(val => matches(val));
- },
redrawTableScrollSave() {
const table = this.$refs.abgabeTable.tabulator;
const scrollX = table.rowManager.scrollLeft;
@@ -1000,92 +693,10 @@ export const AbgabetoolAssistenz = {
}
})
},
- shortLongTitleFormatter(cell, formatterParams, onRendered) {
- const longForm = cell.getValue()
- const shortForm = formatterParams?.shortForm
-
- if(longForm && shortForm) {
- return `
- ${longForm}
-
-
- ${shortForm}
- `
- } else {
- return `
- ${longForm}
- `
- }
-
- },
- toolTipFuncPrevTermin(e, cell, onRendered) {
- const data = cell.getData();
- if(!data.prevTermin) return ''
- return this.mapDateStyleToTabulatorTooltip(data.prevTermin.dateStyle);
- },
- toolTipFuncNextTermin(e, cell, onRendered) {
- const data = cell.getData();
- if(!data.nextTermin) return ''
- return this.mapDateStyleToTabulatorTooltip(data.nextTermin.dateStyle);
- },
- mapDateStyleToTabulatorTooltip(dateStyleString) {
- switch(dateStyleString) {
- case 'bestanden':
- return this.$p.t('abgabetool/c4tooltipBestanden')
- break;
- case 'nichtbestanden':
- return this.$p.t('abgabetool/c4tooltipNichtBestanden')
- break;
- case 'beurteilungerforderlich':
- return this.$p.t('abgabetool/c4tooltipBeurteilungerforderlich')
- break;
- case 'verspaetet':
- return this.$p.t('abgabetool/c4tooltipVerspaetet')
- break;
- case 'abgegeben':
- return this.$p.t('abgabetool/c4tooltipAbgegeben')
- break;
- case 'verpasst':
- return this.$p.t('abgabetool/c4tooltipVerpasst')
- break;
- case 'abzugeben':
- return this.$p.t('abgabetool/c4tooltipAbzugeben')
- break;
- case 'standard':
- return this.$p.t('abgabetool/c4tooltipStandardv2')
- break;
- default: return ''
- }
- },
handlePaUpdated(projektarbeit) {
this.checkAbgabetermineProjektarbeit(projektarbeit)
this.redrawTableScrollSave()
},
- getQGateStatusList() {
- return [
- this.$p.t('abgabetool/c4keinTerminVorhanden'),
- this.$p.t('abgabetool/c4positivBenotet'),
- this.$p.t('abgabetool/c4negativBenotet'),
- this.$p.t('abgabetool/c4notYetGraded'),
- this.$p.t('abgabetool/c4notSubmitted'),
- this.$p.t('abgabetool/c4notHappenedYet')
- ]
- },
- sortFuncTerminCol(a, b, aRow, bRow, column, dir, params) {
- if (a === null || typeof a === "undefined") return 1;
- if (b === null || typeof b === "undefined") return -1;
-
- // try to handle the prev/next interpretation consistently
- // can only make this wrong UX whise so whatever
- if(column._column.field == 'prevTermin') {
- return Math.abs(b.diffMs) - Math.abs(a.diffMs)
- } else if (column._column.field == 'nextTermin') {
- return Math.abs(a.diffMs) - Math.abs(b.diffMs)
- }
-
- // just in case someone reuses this
- return Math.abs(b.diffMs) - Math.abs(a.diffMs)
- },
headerFilterTerminColISO(filterVal, rowVal) {
if (!rowVal) {
return false;
@@ -1121,42 +732,6 @@ export const AbgabetoolAssistenz = {
return false
},
- headerFilterTerminCol(filterVal, rowVal) {
- if (!rowVal || !rowVal.luxonDate || !rowVal.luxonDate.isValid) {
- return false;
- }
-
- const rowDate = rowVal.luxonDate;
-
- const toLuxon = (val) => {
- if (!val) return null;
- let dt;
- if (val instanceof Date) {
- dt = luxon.DateTime.fromJSDate(val);
- } else if (typeof val === "string") {
- dt = toViennaDate(val);
- } else { // fallback
- dt = luxon.DateTime.fromMillis(Number(val));
- }
-
- return dt.isValid ? dt : null;
- };
-
- const von = toLuxon(filterVal[0]);
- const bis = toLuxon(filterVal[1]);
-
- // specific day
- if (von && !bis) {
- return rowDate.hasSame(von, "day");
- }
-
- // range case
- if (von && bis) {
- return rowDate >= von.startOf("day") && rowDate <= bis.endOf("day");
- }
-
- return false
- },
sammelMailStudent(param) {
const recipientList = [];
@@ -1243,94 +818,6 @@ export const AbgabetoolAssistenz = {
e.stopPropagation();
return false;
},
- checkQualityGateStatus(projekt) {
- const qgate1Termine = []
- const qgate2Termine = []
-
- projekt.qgate1Status = this.$p.t('abgabetool/c4keinTerminVorhanden')// 'Kein Termin vorhanden'
- projekt.qgate1StatusRank = 0
- projekt.qgate2Status = this.$p.t('abgabetool/c4keinTerminVorhanden')
- projekt.qgate2StatusRank = 0
-
- projekt.abgabetermine.forEach(termin => {
- if(termin.paabgabetyp_kurzbz == 'qualgate1') qgate1Termine.push(termin)
- if(termin.paabgabetyp_kurzbz == 'qualgate2') qgate2Termine.push(termin)
- })
-
- // calculate qgateStatusRank and display the highest order status rank of all quality gate termine until one
- // counts as passed, which is just a positive note no matter if anything has been uploaded
-
- // reuse luxon calculated diffMs (termin.datum in relation to today) from previous datestyle check
- qgate1Termine.forEach(qgate => {
- if(qgate.note != null && projekt.qgate1StatusRank <= 5) {
- const noteOpt = typeof qgate.note !== 'object' ? this.notenOptions.find(opt => opt.note == qgate.note) : qgate.note
- if(noteOpt.positiv) {
- projekt.qgate1Status = this.$p.t('abgabetool/c4positivBenotet')
- projekt.qgate1StatusRank = 5
- } else {
- projekt.qgate1Status = this.$p.t('abgabetool/c4negativBenotet')
- projekt.qgate1StatusRank = 4
- }
- } else if (qgate.note == null && projekt.qgate1StatusRank <= 3) {
- projekt.qgate1Status = this.$p.t('abgabetool/c4notYetGraded')
- projekt.qgate1StatusRank = 3
- } else if(qgate.upload_allowed == true && qgate.abgabedatum == null && projekt.qgate1StatusRank <= 2) {
- projekt.qgate1Status = this.$p.t('abgabetool/c4notSubmitted')
- projekt.qgate1StatusRank = 2
- } else if (qgate.upload_allowed == false && qgate.diffMs <= 0 && projekt.qgate1StatusRank <= 1) {
- projekt.qgate1Status = this.$p.t('abgabetool/c4notHappenedYet')
- projekt.qgate1StatusRank = 1
- }
- })
-
- qgate2Termine.forEach(qgate => {
- if(qgate.note != null && projekt.qgate1StatusRank <= 5) {
- const noteOpt = typeof qgate.note !== 'object' ? this.notenOptions.find(opt => opt.note == qgate.note) : qgate.note
- if(noteOpt.positiv) {
- projekt.qgate2Status = this.$p.t('abgabetool/c4positivBenotet')
- projekt.qgate2StatusRank = 5
- } else {
- projekt.qgate2Status = this.$p.t('abgabetool/c4negativBenotet')
- projekt.qgate2StatusRank = 4
- }
- } else if (qgate.note == null && projekt.qgate2StatusRank <= 3) {
- projekt.qgate2Status = this.$p.t('abgabetool/c4notYetGraded')
- projekt.qgate2StatusRank = 3
- } else if(qgate.upload_allowed == true && qgate.abgabedatum == null && projekt.qgate2StatusRank <= 2) {
- projekt.qgate2Status = this.$p.t('abgabetool/c4notSubmitted')
- projekt.qgate2StatusRank = 2
- } else if (qgate.upload_allowed == false && qgate.diffMs <= 0 && projekt.qgate2StatusRank <= 1) {
- projekt.qgate2Status = this.$p.t('abgabetool/c4notHappenedYet')
- projekt.qgate2StatusRank = 1
- }
- })
-
- // set shorthand statuscode once real status has been determined
- projekt.qgate1StatusShort = this.mapRankToShortStatus(projekt.qgate1StatusRank)
- projekt.qgate2StatusShort = this.mapRankToShortStatus(projekt.qgate2StatusRank)
- },
- mapRankToShortStatus(rank) {
- switch(rank){
- case 0: // kein termin vorhanden
- return '--'
- break;
- case 1: // noch nicht stattgefunden
- return 'o'
- break;
- case 2: // noch nicht abgegeben
- return '?'
- break;
- case 3: // noch nicht benotet
- return '~'
- break;
- case 4: // negativ benotet
- return '-'
- break;
- case 5: // positiv benotet
- return '+'
- break;
- }
- },
getItemBezeichnung(item){
if(!item.bezeichnung) return ''
@@ -1409,241 +896,15 @@ export const AbgabetoolAssistenz = {
// seperate check for quality gates
this.checkQualityGateStatus(projekt)
},
- loadState() {
- return JSON.parse(localStorage.getItem(this.abgabeTableOptions.persistenceID) || "null");
- },
- saveState(table) {
- // avoid storing state after first restore part happened
- if(!this.stateRestored) return
- const rawLayout = table.getColumnLayout();
- const state = {
- columns: rawLayout.map(col => ({
- field: col.field,
- visible: col.visible,
- width: col.width,
- })),
- sort: table.getSorters().map(s => ({
- field: s.field,
- dir: s.dir,
- })),
- filters: table.getFilters(),
- headerFilters: table.getHeaderFilters()
- };
-
- localStorage.setItem(this.abgabeTableOptions.persistenceID, JSON.stringify(state));
- },
handleTableBuilt() {
- const table = this.$refs.abgabeTable.tabulator
-
this.tableBuiltResolve()
-
- table.on("columnMoved", () => {
- this.saveState(table);
- });
- table.on("columnResized", () => {
- this.saveState(table);
- });
-
- table.on("columnVisibilityChanged", () => {
- this.saveState(table);
- });
-
- table.on("filterChanged", () => {
- this.saveState(table);
- });
-
- table.on("headerFilterChanged", () => {
- this.saveState(table);
- });
-
- table.on("dataSorted", () => {
- this.saveState(table);
- });
-
- table.on("columnSorted", () => {
- this.saveState(table);
- });
-
- table.on("sortersChanged", () => {
- this.saveState(table);
- });
-
- const saved = this.loadState();
-
- table.on("renderComplete", () => {
- if(!this.stateRestored) {
-
- if (saved?.columns && !this.colLayoutRestored) {
- const layout = saved.columns.map(col => ({
- field: col.field,
- width: col.width,
- visible: col.visible,
- // add more if needed, but keep it simple
- }));
-
- table.setColumnLayout(layout);
-
- this.colLayoutRestored = true;
- }
-
- if (saved?.filters && !this.filtersRestored) {
- this.filtersRestored = true // instantly avoid retriggers
- table.setFilter(saved.filters);
- }
- if (saved?.headerFilters && !this.headerFiltersRestored) {
- this.headerFiltersRestored = true // instantly avoid retriggers
- for (let hf of saved.headerFilters) {
- if (hf.field === 'qgate1Status') this.qgate1FilterSelected = hf.value || [];
- if (hf.field === 'qgate2Status') this.qgate2FilterSelected = hf.value || [];
- table.setHeaderFilterValue(hf.field, hf.value);
- }
- }
-
- if (saved?.sort?.length && !this.sortRestored) {
- this.sortRestored = true;
-
- setTimeout(() => {
- const sortList = saved.sort.map(s => {
- const col = table.columnManager.findColumn(s.field);
- if (!col) {
- return null;
- }
- return { column: col, dir: s.dir };
- }).filter(Boolean);
-
- table.setSort(sortList);
- }, 100);
- }
- this.stateRestored = true
-
- // ensure that the filterCollapseables thingy has the correct values
- this.$refs.abgabeTable.setSelectedFields();
-
- }
-
- });
+ this.initTablePersistence(this.$refs.abgabeTable, this.abgabeTableOptions.persistenceID, this.tableState)
},
handleTableBuiltFlat() {
- const table = this.$refs.abgabeTableFlat.tabulator
-
this.tableBuiltResolveFlat()
- table.on("columnMoved", () => {
- this.saveStateFlat(table);
- });
-
- table.on("columnResized", () => {
- this.saveStateFlat(table);
- });
-
- table.on("columnVisibilityChanged", () => {
- this.saveStateFlat(table);
- });
-
- table.on("filterChanged", () => {
- this.saveStateFlat(table);
- });
-
- table.on("headerFilterChanged", () => {
- this.saveStateFlat(table);
- });
-
- table.on("dataSorted", () => {
- this.saveStateFlat(table);
- });
-
- table.on("columnSorted", () => {
- this.saveStateFlat(table);
- });
-
- table.on("sortersChanged", () => {
- this.saveStateFlat(table);
- });
-
- const saved = this.loadStateFlat();
-
- table.on("renderComplete", () => {
- if(!this.stateRestoredFlat) {
-
- if (saved?.columns && !this.colLayoutRestoredFlat) {
- const layout = saved.columns.map(col => ({
- field: col.field,
- width: col.width,
- visible: col.visible,
- // add more if needed, but keep it simple
- }));
-
- table.setColumnLayout(layout);
-
- this.colLayoutRestoredFlat = true;
- }
-
- if (saved?.filters && !this.filtersRestoredFlat) {
- this.filtersRestoredFlat = true // instantly avoid retriggers
- table.setFilter(saved.filters);
- }
- if (saved?.headerFilters && !this.headerFiltersRestoredFlat) {
- this.headerFiltersRestoredFlat = true // instantly avoid retriggers
- for (let hf of saved.headerFilters) {
- if (hf.field === 'note') this.noteFilterSelected = hf.value || [];
- if (hf.field === 'pa_note') this.pa_noteFilterSelected = hf.value || [];
- table.setHeaderFilterValue(hf.field, hf.value);
- }
- }
-
- if (saved?.sort?.length && !this.sortRestoredFlat) {
- this.sortRestoredFlat = true;
-
- setTimeout(() => {
- const sortList = saved.sort.map(s => {
- const col = table.columnManager.findColumn(s.field);
- if (!col) {
- return null;
- }
- return { column: col, dir: s.dir };
- }).filter(Boolean);
-
- table.setSort(sortList);
- }, 100);
- }
- this.stateRestoredFlat = true
-
- // ensure that the filterCollapseables thingy has the correct values
- this.$refs.abgabeTableFlat.setSelectedFields();
-
- }
-
- });
- },
- loadStateFlat() {
- return JSON.parse(localStorage.getItem(this.abgabeTableOptionsFlat.persistenceID) || "null");
- },
- saveStateFlat(table) {
- // avoid storing state after first restore part happened
- if(!this.stateRestoredFlat) return
- const rawLayout = table.getColumnLayout();
- const state = {
- columns: rawLayout.map(col => ({
- field: col.field,
- visible: col.visible,
- width: col.width,
- })),
- sort: table.getSorters().map(s => ({
- field: s.field,
- dir: s.dir,
- })),
- filters: table.getFilters(),
- headerFilters: table.getHeaderFilters()
- };
-
- localStorage.setItem(this.abgabeTableOptionsFlat.persistenceID, JSON.stringify(state));
- },
- handleToggleFullscreenDetail() {
- this.detailIsFullscreen = !this.detailIsFullscreen
- },
- getOptionLabelAbgabetyp(option){
- return this.$p.t('abgabetool/c4paatyp' + option.paabgabetyp_kurzbz)
+ this.initTablePersistence(this.$refs.abgabeTableFlat, this.abgabeTableOptionsFlat.persistenceID, this.tableStateFlat)
},
getOptionLabelStg(option){
return option.kurzbzlang + ' ' + option.bezeichnung
@@ -1654,9 +915,6 @@ export const AbgabetoolAssistenz = {
getNotenFilterOptionLabel(option) {
return option.bezeichnung
},
- formatDate(dateParam) {
- return formatISODate(dateParam);
- },
formAction(cell) {
const actionButtons = document.createElement('div');
actionButtons.className = "d-flex gap-3";
@@ -1707,15 +965,6 @@ export const AbgabetoolAssistenz = {
// this.$api.call(ApiAbgabe.getStudentProjektarbeitAbgabeFile(termin.paabgabe_id, this.projektarbeit.student_uid))
},
- undoSelection(cell) {
- // checks if cells row is selected and unselects -> imitates columns which dont trigger row selection
- // but actually just revert it after the fact
-
- const row = cell.getRow()
- if(row.isSelected()) {
- row.deselect();
- }
- },
selectionCheck(row) {
const data = row.getData()
@@ -1726,14 +975,6 @@ export const AbgabetoolAssistenz = {
selectionCheckFlat(row) {
return row.getData().selectable
},
- showDeadlines(){
- const link = FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router
- + '/Cis/Abgabetool/Deadlines'
- window.open(link, '_blank')
- },
- openAddSeriesModal() {
- this.$refs.modalContainerAddSeries.show()
- },
addSeries() {
const pids = this.selectedData?.map(projekt => projekt.projektarbeit_id)
@@ -1840,21 +1081,6 @@ export const AbgabetoolAssistenz = {
return null
},
- createInfoString(data) {
- let str = '';
-
- data.forEach(name => {
- str += name
- str += '; '
- })
-
- return str
- },
- isPastDate(date) {
- const deadline = luxon.DateTime.fromISO(date, { zone: 'Europe/Vienna' }).endOf('day');
- const nowInVienna = luxon.DateTime.now().setZone('Europe/Vienna');
- return nowInVienna > deadline;
- },
setDetailComponent(details){
const pa = this.projektarbeiten.find(projektarbeit => projektarbeit.projektarbeit_id == details.projektarbeit_id)
@@ -1926,97 +1152,6 @@ export const AbgabetoolAssistenz = {
const key = cell.getValue()
return this.$p.t('abgabetool/c4paatyp' + key)
},
- centeredTextFormatter(cell) {
- const longForm = cell.getValue()
- if(!longForm) return
- const data = cell.getData()
- const entry = Object.entries(data).find(entry => entry[1] == longForm)
-
- // shortFormKey must have same keyname as longForm but with 'Short' appended
- const shortForm = data[entry[0]+'Short']
-
- if(shortForm && longForm) {
- return `
-
- ${longForm}
-
-
- ${shortForm}
-
-
`;
- } else {
- return ''
- }
- },
- pkzTextFormatter(cell) {
- const val = cell.getValue()
-
- return ''
- },
- abgabeterminFormatter(cell, formatterParams, onRendered,) {
- const val = cell.getValue()
- const dateStyle = val?.dateStyle ?? val
-
-
- if(val) {
- let icon = ''
- switch(dateStyle) {
- case 'verspaetet':
- icon = ''
- break
- case 'verpasst':
- icon = ''
- break
- case 'abzugeben':
- icon = ''
- break
- case 'standard':
- icon = ''
- break
- case 'abgegeben':
- icon = ''
- break
- case 'beurteilungerforderlich':
- icon = ''
- break
- case 'bestanden':
- icon = ''
- break
- case 'nichtbestanden':
- icon = ''
- break
- }
-
- const typKurzbz = val.paabgabetyp_kurzbz ?? val.bezeichnung?.paabgabetyp_kurzbz
- const bezeichnung = this.$p.t('abgabetool/c4paatyp' + typKurzbz)
-
- if(formatterParams?.iconOnly) {
- return '' +
- '' +
- '
'
- }
-
- return '' +
- '' +
- '
' +
- '
'+bezeichnung+' - '+ this.formatDate(val.datum)+'
' +
- '
'+
- '
'
-
- } else {
- return ''
- }
-
- },
- tableResolve(resolve) {
- this.tableBuiltResolve = resolve
- },
tableResolveFlat(resolve) {
this.tableBuiltResolveFlat = resolve
},
@@ -2026,9 +1161,6 @@ export const AbgabetoolAssistenz = {
buildPKZ(projekt) {
return `${projekt.student_uid} / ${projekt.matrikelnr}`
},
- buildStg(projekt) {
- return (projekt.typ + projekt.kurzbz)?.toUpperCase()
- },
buildErstbetreuer(projekt) {
if(projekt.erstbetreuer_full_name) return projekt.erstbetreuer_full_name
return projekt.betreuer_vorname + ' ' + projekt.betreuer_nachname
@@ -2077,17 +1209,6 @@ export const AbgabetoolAssistenz = {
this.loading = false
})
},
- loadAbgaben(details) {
- return new Promise((resolve) => {
- this.$api.call(ApiAbgabe.getStudentProjektabgaben(details))
- .then(res => {
- resolve(res)
- })
- })
- },
- handleUuidDefined(uuid) {
- this.tabulatorUuid = uuid
- },
handleUuidDefinedFlat(uuid) {
this.tabulatorUuidFlat = uuid
},
@@ -2172,17 +1293,6 @@ export const AbgabetoolAssistenz = {
+ this.$p.t('global/gesamt')
+ ': ' + (this.countFlat || 0) + '';
},
- countsToHTML() {
- return this.$p.t('global/ausgewaehlt')
- + ': ' + (this.selectedcount || 0) + ''
- + ' | '
- + this.$p.t('global/gefiltert')
- + ': '
- + '' + (this.filteredcount || 0) + ''
- + ' | '
- + this.$p.t('global/gesamt')
- + ': ' + (this.count || 0) + '';
- },
emailItems() {
const menu = []
diff --git a/public/js/components/Cis/Abgabetool/AbgabetoolMitarbeiter.js b/public/js/components/Cis/Abgabetool/AbgabetoolMitarbeiter.js
index b13010cad..d29ced07c 100644
--- a/public/js/components/Cis/Abgabetool/AbgabetoolMitarbeiter.js
+++ b/public/js/components/Cis/Abgabetool/AbgabetoolMitarbeiter.js
@@ -7,10 +7,12 @@ import FhcOverlay from "../../Overlay/FhcOverlay.js";
import { getDateStyleClass } from "./getDateStyleClass.js";
import { dateFilter } from '../../../tabulator/filters/DatesManual.js';
import {splitMailsHelper} from "../../../helpers/EmailHelpers.js";
-import { formatISODate, getViennaTodayISO, toViennaDate } from "./dateUtils.js";
+import { getViennaTodayISO, toViennaDate } from "./dateUtils.js";
+import AbgabetoolTableMixin from "./AbgabetoolTableMixin.js";
export const AbgabetoolMitarbeiter = {
name: "AbgabetoolMitarbeiter",
+ mixins: [AbgabetoolTableMixin],
components: {
BsModal,
CoreFilterCmpt,
@@ -38,8 +40,8 @@ export const AbgabetoolMitarbeiter = {
count: 0,
filteredcount: 0,
selectedcount: 0,
- qgate1FilterSelected: [],
- qgate2FilterSelected: [],
+ // restore flags of the table, filled by initTablePersistence
+ tableState: {},
abgabetypenBetreuer: null,
detailIsFullscreen: false,
phrasenPromise: null,
@@ -145,7 +147,12 @@ export const AbgabetoolMitarbeiter = {
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4orgformv2'))), field: 'orgform', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 50, visible: false},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4projekttyp'))), field: 'projekttyp_kurzbz', formatter: this.centeredTextFormatter, minWidth: 100,visible: true},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4stg'))), field: 'stg', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 50, visible: true},
- {title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4note'))), field: 'note_bez', headerFilter: true, visible: false, sorter: this.notenSorter, minWidth: 200, formatter: this.centeredTextFormatter},
+ {title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4note'))), field: 'note_bez',
+ headerFilter: this.notenHeaderFilterEditor,
+ headerFilterFunc: this.notenHeaderFilterFunc,
+ headerFilterParams: {},
+ headerFilterFuncParams: { idField: 'note' }, // column shows the bezeichnung, filter compares the note id
+ visible: false, sorter: this.notenSorter, minWidth: 200, formatter: this.centeredTextFormatter},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4sem'))), field: 'studiensemester_kurzbz', headerFilter: true, formatter: this.centeredTextFormatter, visible: true, minWidth: 100},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4titel'))), field: 'titel', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 100, width: 500, visible: true},
{title: Vue.computed(() => this.$capitalize(this.$p.t('abgabetool/c4erstbetreuerv2'))), field: 'betreuer_full_name', headerFilter: true, formatter: this.centeredTextFormatter, minWidth: 100, visible: false},
@@ -265,11 +272,6 @@ export const AbgabetoolMitarbeiter = {
]};
},
methods: {
- notenSorter(a, b, aRow, bRow, column, dir, sorterParams) {
- const aData = aRow.getData()
- const bData = bRow.getData()
- return aData.note - bData.note
- },
async openBenotung(type, link) {
if(type === 'new') {
window.open(link, '_blank')
@@ -360,268 +362,6 @@ export const AbgabetoolMitarbeiter = {
if(res.meta.status == 'success') this.$fhcAlert.alertSuccess(this.$p.t('abgabetool/c4zweitBegutachterTokenMailSuccess'))
})
},
- getDateStyleHtml(dateStyle) {
- const iconMap = {
- 'verspaetet': '',
- 'verpasst': '',
- 'abzugeben': '',
- 'standard': '',
- 'abgegeben': '',
- 'beurteilungerforderlich': '',
- 'bestanden': '',
- 'nichtbestanden': '',
- };
- return iconMap[dateStyle] ?? '';
- },
- statusHeaderFilterEditor(cell, onRendered, success, cancel, editorParams) {
- const options = [
- { label: this.$p.t('abgabetool/c4positivBenotet'), value: 'bestanden', dateStyle: 'bestanden' },
- { label: this.$p.t('abgabetool/c4negativBenotet'), value: 'nichtbestanden', dateStyle: 'nichtbestanden' },
- { label: this.$p.t('abgabetool/c4tooltipVerspaetet'), value: 'verspaetet', dateStyle: 'verspaetet' },
- { label: this.$p.t('abgabetool/c4tooltipVerpasst'), value: 'verpasst', dateStyle: 'verpasst' },
- { label: this.$p.t('abgabetool/c4tooltipAbzugeben'), value: 'abzugeben', dateStyle: 'abzugeben' },
- { label: this.$p.t('abgabetool/c4tooltipAbgegeben'), value: 'abgegeben', dateStyle: 'abgegeben' },
- { label: this.$p.t('abgabetool/c4tooltipBeurteilungerforderlich'), value: 'beurteilungerforderlich', dateStyle: 'beurteilungerforderlich' },
- { label: this.$p.t('abgabetool/c4tooltipStandardv2'), value: 'standard', dateStyle: 'standard' },
- ];
-
- const field = cell.getField();
- const stateKey = field + 'FilterSelected'; // e.g. dateStyleFilterSelected
- let selected = [...(this[stateKey] || [])];
-
- const wrapper = document.createElement('div');
- wrapper.style.cssText = 'position: relative; width: 100%;';
-
- const display = document.createElement('input');
- display.readOnly = true;
- display.placeholder = '';
- display.style.cssText = 'padding: 4px; width: 100%; box-sizing: border-box; cursor: default; border: 1px solid; outline: none; background: #fff; appearance: none; caret-color: transparent;';
-
- const dropdown = document.createElement('div');
- dropdown.style.cssText = 'display: none; position: fixed; background: #fff; border: 1px solid; z-index: 9999; min-width: 220px; box-shadow: 0 2px 6px rgba(0,0,0,0.15);';
-
- const updateDisplay = () => {
- display.value = options
- .filter(o => selected.includes(o.value))
- .map(o => o.label)
- .join(', ');
- };
-
- options.forEach(opt => {
- const row = document.createElement('label');
- row.style.cssText = 'display: flex; align-items: center; gap: 0; cursor: pointer; white-space: nowrap; padding-right: 8px;';
- row.addEventListener('mousedown', e => e.preventDefault());
-
- const cb = document.createElement('input');
- cb.type = 'checkbox';
- cb.value = opt.value;
- cb.checked = selected.includes(opt.value);
- cb.style.cssText = 'margin: 0 6px;';
- cb.addEventListener('change', () => {
- selected = cb.checked
- ? [...selected, opt.value]
- : selected.filter(v => v !== opt.value);
- this[stateKey] = [...selected];
- updateDisplay();
- success([...selected]);
- });
-
- // icon badge — same look as cell
- const badge = document.createElement('div');
- badge.className = opt.dateStyle + '-header';
- badge.style.cssText = `min-width: 36px; height: 36px; display: flex; align-items: center;
- justify-content: center; flex-shrink: 0; padding: 0px 17px 0px 17px;`;
- badge.innerHTML = this.getDateStyleHtml(opt.dateStyle);
-
- const labelText = document.createElement('span');
- labelText.textContent = opt.label;
- labelText.style.cssText = 'margin-left: 6px;';
-
- row.appendChild(cb);
- row.appendChild(badge);
- row.appendChild(labelText);
- dropdown.appendChild(row);
- });
-
- updateDisplay();
-
- display.addEventListener('click', () => {
- if (dropdown.style.display === 'none') {
- const rect = display.getBoundingClientRect();
- dropdown.style.top = rect.bottom + 'px';
- dropdown.style.left = rect.left + 'px';
- dropdown.style.display = 'block';
- } else {
- dropdown.style.display = 'none';
- }
- });
-
- display.addEventListener('blur', () => {
- setTimeout(() => { dropdown.style.display = 'none'; }, 150);
- });
-
- document.body.appendChild(dropdown);
- wrapper.appendChild(display);
- cell.getElement().addEventListener('remove', () => dropdown.remove());
- onRendered(() => display.focus());
-
- return wrapper;
- },
- statusHeaderFilterFunc(filterVal, rowVal, rowData, filterParams) {
- if (!filterVal || !filterVal.length) return true;
- // rowVal is the raw dateStyle string on the flat table
- return filterVal.some(val => val === rowVal);
- },
- qgateHeaderFilterEditor(cell, onRendered, success, cancel, editorParams) {
-
- const options = [
- { label: '[+] ' + this.$p.t('abgabetool/c4positivBenotet'), value: 'positive' },
- { label: '[-] ' + this.$p.t('abgabetool/c4negativBenotet'), value: 'negative' },
- { label: '[~] ' + this.$p.t('abgabetool/c4notYetGraded'), value: 'not_graded' },
- { label: '[?] ' + this.$p.t('abgabetool/c4notSubmitted'), value: 'not_submitted' },
- { label: '[o] ' + this.$p.t('abgabetool/c4notHappenedYet'), value: 'not_happened' },
- { label: '[--] ' + this.$p.t('abgabetool/c4keinTerminVorhanden'), value: 'no_termin' },
- ];
-
- const field = cell.getField();
- const stateKey = field === 'qgate1Status' ? 'qgate1FilterSelected' : 'qgate2FilterSelected';
- let selected = [...(this[stateKey] || [])]; // restore persistence state
-
- const wrapper = document.createElement('div');
- wrapper.style.cssText = 'position: relative; width: 100%;';
-
- const display = document.createElement('input');
- display.readOnly = true;
- display.placeholder = '';
- display.style.cssText = 'padding: 4px; width: 100%; box-sizing: border-box; cursor: default; border: 1px solid; outline: none; background: #fff; appearance: none; caret-color: transparent;';
-
- const dropdown = document.createElement('div');
- dropdown.style.cssText = 'display: none; position: fixed; background: #fff; border: 1px solid; z-index: 9999; min-width: 180px; box-shadow: 0 2px 6px rgba(0,0,0,0.15);';
-
- options.forEach(opt => {
- const row = document.createElement('label');
- row.style.cssText = 'display: flex; align-items: center; gap: 6px; padding: 4px 8px; cursor: pointer; white-space: nowrap;';
- row.addEventListener('mousedown', e => e.preventDefault());
-
- const cb = document.createElement('input');
- cb.type = 'checkbox';
- cb.value = opt.value;
- cb.checked = selected.includes(opt.value); // sync with persistence
- cb.addEventListener('change', () => {
- if (cb.checked) {
- selected.push(opt.value);
- } else {
- selected = selected.filter(v => v !== opt.value);
- }
- this[stateKey] = [...selected]; // sync with persistence
- display.value = options.filter(o => selected.includes(o.value)).map(o => o.label).join(', ');
- success([...selected]);
- });
-
- row.appendChild(cb);
- row.appendChild(document.createTextNode(opt.label));
- dropdown.appendChild(row);
- });
-
- display.value = options.filter(o => selected.includes(o.value)).map(o => o.label).join(', ');
-
- display.addEventListener('click', () => {
- if (dropdown.style.display === 'none') {
- const rect = display.getBoundingClientRect();
- dropdown.style.top = rect.bottom + 'px';
- dropdown.style.left = rect.left + 'px';
- dropdown.style.display = 'block';
- } else {
- dropdown.style.display = 'none';
- }
- });
-
- display.addEventListener('blur', () => {
- setTimeout(() => { dropdown.style.display = 'none'; }, 150);
- });
-
- document.body.appendChild(dropdown);
- wrapper.appendChild(display);
-
- cell.getElement().addEventListener('remove', () => dropdown.remove());
-
- onRendered(() => display.focus());
-
- return wrapper;
- },
- qgateHeaderFilterFunc(filterVal, rowVal, rowData, filterParams) {
- if (!filterVal || !filterVal.length) return true;
-
- const matches = (val) => {
- switch (val) {
- case 'positive': return rowVal === this.$p.t('abgabetool/c4positivBenotet');
- case 'negative': return rowVal === this.$p.t('abgabetool/c4negativBenotet');
- case 'not_graded': return rowVal === this.$p.t('abgabetool/c4notYetGraded');
- case 'not_submitted':return rowVal === this.$p.t('abgabetool/c4notSubmitted');
- case 'not_happened': return rowVal === this.$p.t('abgabetool/c4notHappenedYet');
- case 'no_termin': return rowVal === this.$p.t('abgabetool/c4keinTerminVorhanden');
- default: return true;
- }
- };
-
- // OR logic — row passes if it matches any selected filter
- return filterVal.some(val => matches(val));
- },
- shortLongTitleFormatter(cell, formatterParams, onRendered) {
- const longForm = cell.getValue()
- const shortForm = formatterParams?.shortForm
-
- if(longForm && shortForm) {
- return `
- ${longForm}
-
-
- ${shortForm}
- `
- } else {
- return `
- ${longForm}
- `
- }
-
- },
- toolTipFuncPrevTermin(e, cell, onRendered) {
- const data = cell.getData();
- return this.mapDateStyleToTabulatorTooltip(data.prevTermin.dateStyle);
- },
- toolTipFuncNextTermin(e, cell, onRendered) {
- const data = cell.getData();
- return this.mapDateStyleToTabulatorTooltip(data.nextTermin.dateStyle);
- },
- mapDateStyleToTabulatorTooltip(dateStyleString) {
- switch(dateStyleString) {
- case 'bestanden':
- return this.$p.t('abgabetool/c4tooltipBestanden')
- break;
- case 'nichtbestanden':
- return this.$p.t('abgabetool/c4tooltipNichtBestanden')
- break;
- case 'beurteilungerforderlich':
- return this.$p.t('abgabetool/c4tooltipBeurteilungerforderlich')
- break;
- case 'verspaetet':
- return this.$p.t('abgabetool/c4tooltipVerspaetet')
- break;
- case 'abgegeben':
- return this.$p.t('abgabetool/c4tooltipAbgegeben')
- break;
- case 'verpasst':
- return this.$p.t('abgabetool/c4tooltipVerpasst')
- break;
- case 'abzugeben':
- return this.$p.t('abgabetool/c4tooltipAbzugeben')
- break;
- case 'standard':
- return this.$p.t('abgabetool/c4tooltipStandardv2')
- break;
- default: return ''
- }
- },
handlePaUpdated(projektarbeit) {
this.checkAbgabetermineProjektarbeit(projektarbeit)
this.$refs.abgabeTable.tabulator.redraw(true)
@@ -636,267 +376,10 @@ export const AbgabetoolMitarbeiter = {
const subject = ""; // empty subject line
splitMailsHelper(uniqueRecipients, param.originalEvent, subject, null, this.$fhcAlert, this.$p)
},
- getQGateStatusList() {
- return [
- this.$p.t('abgabetool/c4keinTerminVorhanden'),
- this.$p.t('abgabetool/c4positivBenotet'),
- this.$p.t('abgabetool/c4negativBenotet'),
- this.$p.t('abgabetool/c4notYetGraded'),
- this.$p.t('abgabetool/c4notSubmitted'),
- this.$p.t('abgabetool/c4notHappenedYet')
- ]
- },
- sortFuncTerminCol(a, b, aRow, bRow, column, dir, params) {
- if (a === null || typeof a === "undefined") return 1;
- if (b === null || typeof b === "undefined") return -1;
-
- // try to handle the prev/next interpretation consistently
- // can only make this wrong UX whise so whatever
- if(column._column.field == 'prevTermin') {
- return Math.abs(b.diffMs) - Math.abs(a.diffMs)
- } else if (column._column.field == 'nextTermin') {
- return Math.abs(a.diffMs) - Math.abs(b.diffMs)
- }
-
- // just in case someone reuses this
- return Math.abs(b.diffMs) - Math.abs(a.diffMs)
- },
- headerFilterTerminCol(filterVal, rowVal) {
- if (!rowVal || !rowVal.luxonDate || !rowVal.luxonDate.isValid) {
- return false;
- }
-
- const rowDate = rowVal.luxonDate;
-
- const toLuxon = (val) => {
- if (!val) return null;
- let dt;
- if (val instanceof Date) {
- dt = luxon.DateTime.fromJSDate(val);
- } else if (typeof val === "string") {
- dt = toViennaDate(val);
- } else { // fallback
- dt = luxon.DateTime.fromMillis(Number(val));
- }
-
- return dt.isValid ? dt : null;
- };
-
- const von = toLuxon(filterVal[0]);
- const bis = toLuxon(filterVal[1]);
-
- // specific day
- if (von && !bis) {
- return rowDate.hasSame(von, "day");
- }
-
- // range case
- if (von && bis) {
- return rowDate >= von.startOf("day") && rowDate <= bis.endOf("day");
- }
-
- return false
- },
- loadState() {
- return JSON.parse(localStorage.getItem(this.abgabeTableOptions.persistenceID) || "null");
- },
- saveState(table) {
- // avoid storing state after first restore part happened
- if(!this.stateRestored) return
- const rawLayout = table.getColumnLayout();
- const state = {
- columns: rawLayout.map(col => ({
- field: col.field,
- visible: col.visible,
- width: col.width,
- })),
- sort: table.getSorters().map(s => ({
- field: s.field,
- dir: s.dir,
- })),
- filters: table.getFilters(),
- headerFilters: table.getHeaderFilters()
- };
-
- localStorage.setItem(this.abgabeTableOptions.persistenceID, JSON.stringify(state));
- },
handleTableBuilt() {
- const table = this.$refs.abgabeTable.tabulator
-
this.tableBuiltResolve()
- table.on("columnMoved", () => {
- this.saveState(table);
- });
-
- table.on("columnResized", () => {
- this.saveState(table);
- });
-
- table.on("columnVisibilityChanged", () => {
- this.saveState(table);
- });
-
- table.on("filterChanged", () => {
- this.saveState(table);
- });
-
- table.on("headerFilterChanged", () => {
- this.saveState(table);
- });
-
- table.on("dataSorted", () => {
- this.saveState(table);
- });
-
- table.on("columnSorted", () => {
- this.saveState(table);
- });
-
- table.on("sortersChanged", () => {
- this.saveState(table);
- });
-
- const saved = this.loadState();
-
- table.on("renderComplete", () => {
- if(!this.stateRestored) {
-
- if (saved?.columns && !this.colLayoutRestored) {
- const layout = saved.columns.map(col => ({
- field: col.field,
- width: col.width,
- visible: col.visible,
- // add more if needed, but keep it simple
- }));
-
- table.setColumnLayout(layout);
-
- this.colLayoutRestored = true;
- }
-
- if (saved?.filters && !this.filtersRestored) {
- this.filtersRestored = true // instantly avoid retriggers
- table.setFilter(saved.filters);
- }
- if (saved?.headerFilters && !this.headerFiltersRestored) {
- this.headerFiltersRestored = true // instantly avoid retriggers
- for (let hf of saved.headerFilters) {
- table.setHeaderFilterValue(hf.field, hf.value);
- }
- }
-
- if (saved?.sort?.length && !this.sortRestored) {
- this.sortRestored = true;
-
- setTimeout(() => {
- const sortList = saved.sort.map(s => {
- const col = table.columnManager.findColumn(s.field);
- if (!col) {
- return null;
- }
- return { column: col, dir: s.dir };
- }).filter(Boolean);
-
- table.setSort(sortList);
- }, 100);
- }
- this.stateRestored = true
-
- // ensure that the filterCollapseables thingy has the correct values
- this.$refs.abgabeTable.setSelectedFields();
-
- }
-
- });
- },
- checkQualityGateStatus(projekt) {
- const qgate1Termine = []
- const qgate2Termine = []
-
- projekt.qgate1Status = this.$p.t('abgabetool/c4keinTerminVorhanden')// 'Kein Termin vorhanden'
- projekt.qgate1StatusRank = 0
- projekt.qgate2Status = this.$p.t('abgabetool/c4keinTerminVorhanden')
- projekt.qgate2StatusRank = 0
-
- projekt.abgabetermine.forEach(termin => {
- if(termin.paabgabetyp_kurzbz == 'qualgate1') qgate1Termine.push(termin)
- if(termin.paabgabetyp_kurzbz == 'qualgate2') qgate2Termine.push(termin)
- })
-
- // calculate qgateStatusRank and display the highest order status rank of all quality gate termine until one
- // counts as passed, which is just a positive note no matter if anything has been uploaded
-
- // reuse luxon calculated diffMs (termin.datum in relation to today) from previous datestyle check
- qgate1Termine.forEach(qgate => {
- if(qgate.note != null && projekt.qgate1StatusRank <= 5) {
- const noteOpt = typeof qgate.note !== 'object' ? this.notenOptions.find(opt => opt.note == qgate.note) : qgate.note
- if(noteOpt.positiv) {
- projekt.qgate1Status = this.$p.t('abgabetool/c4positivBenotet')
- projekt.qgate1StatusRank = 5
- } else {
- projekt.qgate1Status = this.$p.t('abgabetool/c4negativBenotet')
- projekt.qgate1StatusRank = 4
- }
- } else if (qgate.note == null && projekt.qgate1StatusRank <= 3) {
- projekt.qgate1Status = this.$p.t('abgabetool/c4notYetGraded')
- projekt.qgate1StatusRank = 3
- } else if(qgate.upload_allowed == true && qgate.abgabedatum == null && projekt.qgate1StatusRank <= 2) {
- projekt.qgate1Status = this.$p.t('abgabetool/c4notSubmitted')
- projekt.qgate1StatusRank = 2
- } else if (qgate.upload_allowed == false && qgate.diffMs <= 0 && projekt.qgate1StatusRank <= 1) {
- projekt.qgate1Status = this.$p.t('abgabetool/c4notHappenedYet')
- projekt.qgate1StatusRank = 1
- }
- })
-
- qgate2Termine.forEach(qgate => {
- if(qgate.note != null && projekt.qgate1StatusRank <= 5) {
- const noteOpt = typeof qgate.note !== 'object' ? this.notenOptions.find(opt => opt.note == qgate.note) : qgate.note
- if(noteOpt.positiv) {
- projekt.qgate2Status = this.$p.t('abgabetool/c4positivBenotet')
- projekt.qgate2StatusRank = 5
- } else {
- projekt.qgate2Status = this.$p.t('abgabetool/c4negativBenotet')
- projekt.qgate2StatusRank = 4
- }
- } else if (qgate.note == null && projekt.qgate2StatusRank <= 3) {
- projekt.qgate2Status = this.$p.t('abgabetool/c4notYetGraded')
- projekt.qgate2StatusRank = 3
- } else if(qgate.upload_allowed == true && qgate.abgabedatum == null && projekt.qgate2StatusRank <= 2) {
- projekt.qgate2Status = this.$p.t('abgabetool/c4notSubmitted')
- projekt.qgate2StatusRank = 2
- } else if (qgate.upload_allowed == false && qgate.diffMs <= 0 && projekt.qgate2StatusRank <= 1) {
- projekt.qgate2Status = this.$p.t('abgabetool/c4notHappenedYet')
- projekt.qgate2StatusRank = 1
- }
- })
-
- // set shorthand statuscode once real status has been determined
- projekt.qgate1StatusShort = this.mapRankToShortStatus(projekt.qgate1StatusRank)
- projekt.qgate2StatusShort = this.mapRankToShortStatus(projekt.qgate2StatusRank)
- },
- mapRankToShortStatus(rank) {
- switch(rank){
- case 0: // kein termin vorhanden
- return '--'
- break;
- case 1: // noch nicht stattgefunden
- return 'o'
- break;
- case 2: // noch nicht abgegeben
- return '?'
- break;
- case 3: // noch nicht benotet
- return '~'
- break;
- case 4: // negativ benotet
- return '-'
- break;
- case 5: // positiv benotet
- return '+'
- break;
- }
+ this.initTablePersistence(this.$refs.abgabeTable, this.abgabeTableOptions.persistenceID, this.tableState)
},
checkAbgabetermineProjektarbeit(projekt) {
const now = luxon.DateTime.now()
@@ -928,55 +411,6 @@ export const AbgabetoolMitarbeiter = {
// seperate check for quality gates
this.checkQualityGateStatus(projekt)
},
- abgabeterminFormatter(cell) {
- const val = cell.getValue()
-
- if(val) {
- let icon = ''
- switch(val.dateStyle) {
- case 'verspaetet':
- icon = ''
- break
- case 'verpasst':
- icon = ''
- break
- case 'abzugeben':
- icon = ''
- break
- case 'standard':
- icon = ''
- break
- case 'abgegeben':
- icon = ''
- break
- case 'beurteilungerforderlich':
- icon = ''
- break
- case 'bestanden':
- icon = ''
- break
- case 'nichtbestanden':
- icon = ''
- break
- }
-
- const typKurzbz = val.paabgabetyp_kurzbz ?? val.bezeichnung?.paabgabetyp_kurzbz
- const bezeichnung = this.$p.t('abgabetool/c4paatyp' + typKurzbz)
-
- return '' +
- '' +
- '
' +
- '
'+bezeichnung+' - '+ this.formatDate(val.datum)+'
' +
- '
'+
- '
'
-
- } else {
- return ''
- }
-
- },
selectHandler(e, cell) {
const row = cell.getRow();
@@ -1011,15 +445,6 @@ export const AbgabetoolMitarbeiter = {
e.stopPropagation();
return false;
},
- handleToggleFullscreenDetail() {
- this.detailIsFullscreen = !this.detailIsFullscreen
- },
- getOptionLabelAbgabetyp(option){
- return this.$p.t('abgabetool/c4paatyp' + option.paabgabetyp_kurzbz)
- },
- formatDate(dateParam) {
- return formatISODate(dateParam);
- },
undoSelection(cell) {
// checks if cells row is selected and unselects -> imitates columns which dont trigger row selection
// but actually just revert it after the fact
@@ -1050,9 +475,6 @@ export const AbgabetoolMitarbeiter = {
this.loading = false
})
},
- openAddSeriesModal() {
- this.$refs.modalContainerAddSeries.show()
- },
addSeries() {
this.saving = true
this.$api.call(ApiAbgabe.postSerientermin(
@@ -1093,21 +515,6 @@ export const AbgabetoolMitarbeiter = {
this.$refs.modalContainerAddSeries.hide()
},
- createInfoString(data) {
- let str = '';
-
- data.forEach(name => {
- str += name
- str += '; '
- })
-
- return str
- },
- isPastDate(date) {
- const deadline = luxon.DateTime.fromISO(date, { zone: 'Europe/Vienna' }).endOf('day');
- const nowInVienna = luxon.DateTime.now().setZone('Europe/Vienna');
- return nowInVienna > deadline;
- },
setDetailComponent(details){
this.loading=true
@@ -1170,51 +577,16 @@ export const AbgabetoolMitarbeiter = {
this.loading = false
},
- centeredTextFormatter(cell) {
- const longForm = cell.getValue()
- if(!longForm) return
- const data = cell.getData()
- const entry = Object.entries(data).find(entry => entry[1] == longForm)
-
- // shortFormKey must have same keyname as longForm but with 'Short' appended
- const shortForm = data[entry[0]+'Short']
-
- if(shortForm && longForm) {
- return `
-
- ${longForm}
-
-
- ${shortForm}
-
-
`;
- } else {
- return ''
- }
- },
detailFormatter(cell) {
return ''
},
- pkzTextFormatter(cell) {
- const val = cell.getValue()
-
- return ''
- },
- tableResolve(resolve) {
- this.tableBuiltResolve = resolve
- },
buildMailToLink(abgabe) {
return 'mailto:' + abgabe.uid +'@'+ this.domain
},
buildPKZ(projekt) {
return `${projekt.uid} / ${projekt.matrikelnr}`
},
- buildStg(projekt) {
- return (projekt.typ + projekt.kurzbz)?.toUpperCase()
- },
setupData(data){
this.domain = data[1]
@@ -1259,17 +631,6 @@ export const AbgabetoolMitarbeiter = {
}
})
},
- loadAbgaben(details) {
- return new Promise((resolve) => {
- this.$api.call(ApiAbgabe.getStudentProjektabgaben(details))
- .then(res => {
- resolve(res)
- })
- })
- },
- handleUuidDefined(uuid) {
- this.tabulatorUuid = uuid
- },
calcMaxTableHeight() {
const tableID = this.tabulatorUuid ? ('-' + this.tabulatorUuid) : ''
const tableDataSet = document.getElementById('filterTableDataset' + tableID);
@@ -1299,17 +660,6 @@ export const AbgabetoolMitarbeiter = {
}
},
computed: {
- countsToHTML() {
- return this.$p.t('global/ausgewaehlt')
- + ': ' + (this.selectedcount || 0) + ''
- + ' | '
- + this.$p.t('global/gefiltert')
- + ': '
- + '' + (this.filteredcount || 0) + ''
- + ' | '
- + this.$p.t('global/gesamt')
- + ': ' + (this.count || 0) + '';
- },
emailItems() {
const menu = []
diff --git a/public/js/components/Cis/Abgabetool/AbgabetoolTableMixin.js b/public/js/components/Cis/Abgabetool/AbgabetoolTableMixin.js
new file mode 100644
index 000000000..8acf436f4
--- /dev/null
+++ b/public/js/components/Cis/Abgabetool/AbgabetoolTableMixin.js
@@ -0,0 +1,544 @@
+import { multiSelectHeaderFilter } from '../../../tabulator/filters/multiSelectHeaderFilter.js';
+import { formatISODate, toViennaDate } from './dateUtils.js';
+
+// Shared projektarbeiten table logic of the Assistenz & Betreuer view: column formatters, sorters,
+// header filters, quality gate status and the localStorage persistence of the table state.
+export const AbgabetoolTableMixin = {
+ data() {
+ return {
+ // selected values of the multiselect header filters, keyed by column field
+ headerFilterSelections: {}
+ }
+ },
+ computed: {
+ countsToHTML() {
+ return this.$p.t('global/ausgewaehlt')
+ + ': ' + (this.selectedcount || 0) + ''
+ + ' | '
+ + this.$p.t('global/gefiltert')
+ + ': '
+ + '' + (this.filteredcount || 0) + ''
+ + ' | '
+ + this.$p.t('global/gesamt')
+ + ': ' + (this.count || 0) + '';
+ }
+ },
+ methods: {
+ headerFilterSelection(field) {
+ return {
+ get: () => this.headerFilterSelections[field] ?? [],
+ set: (values) => { this.headerFilterSelections[field] = [...values] }
+ }
+ },
+ getStatusFilterOptions() {
+ return [
+ { label: this.$p.t('abgabetool/c4positivBenotet'), value: 'bestanden' },
+ { label: this.$p.t('abgabetool/c4negativBenotet'), value: 'nichtbestanden' },
+ { label: this.$p.t('abgabetool/c4tooltipVerspaetet'), value: 'verspaetet' },
+ { label: this.$p.t('abgabetool/c4tooltipVerpasst'), value: 'verpasst' },
+ { label: this.$p.t('abgabetool/c4tooltipAbzugeben'), value: 'abzugeben' },
+ { label: this.$p.t('abgabetool/c4tooltipAbgegeben'), value: 'abgegeben' },
+ { label: this.$p.t('abgabetool/c4tooltipBeurteilungerforderlich'), value: 'beurteilungerforderlich' },
+ { label: this.$p.t('abgabetool/c4tooltipStandardv2'), value: 'standard' },
+ ].map(opt => ({
+ ...opt,
+ badge: { cssClass: opt.value + '-header', html: this.getDateStyleHtml(opt.value) }
+ }));
+ },
+ statusHeaderFilterEditor(cell, onRendered, success, cancel) {
+ return multiSelectHeaderFilter(cell, onRendered, success, cancel, {
+ options: () => this.getStatusFilterOptions(),
+ selected: this.headerFilterSelection(cell.getField()),
+ minWidth: '220px'
+ });
+ },
+ statusHeaderFilterFunc(filterVal, rowVal, rowData, filterParams) {
+ if (!filterVal || !filterVal.length) return true;
+ // rowVal is the raw dateStyle string on the flat table
+ return filterVal.some(val => val === rowVal);
+ },
+ getQgateFilterOptions() {
+ return [
+ { label: '[+] ' + this.$p.t('abgabetool/c4positivBenotet'), value: 'positive' },
+ { label: '[-] ' + this.$p.t('abgabetool/c4negativBenotet'), value: 'negative' },
+ { label: '[~] ' + this.$p.t('abgabetool/c4notYetGraded'), value: 'not_graded' },
+ { label: '[?] ' + this.$p.t('abgabetool/c4notSubmitted'), value: 'not_submitted' },
+ { label: '[o] ' + this.$p.t('abgabetool/c4notHappenedYet'), value: 'not_happened' },
+ { label: '[--] ' + this.$p.t('abgabetool/c4keinTerminVorhanden'), value: 'no_termin' },
+ ];
+ },
+ qgateHeaderFilterEditor(cell, onRendered, success, cancel) {
+ return multiSelectHeaderFilter(cell, onRendered, success, cancel, {
+ options: () => this.getQgateFilterOptions(),
+ selected: this.headerFilterSelection(cell.getField())
+ });
+ },
+ qgateHeaderFilterFunc(filterVal, rowVal, rowData, filterParams) {
+ if (!filterVal || !filterVal.length) return true;
+
+ const matches = (val) => {
+ switch (val) {
+ case 'positive': return rowVal === this.$p.t('abgabetool/c4positivBenotet');
+ case 'negative': return rowVal === this.$p.t('abgabetool/c4negativBenotet');
+ case 'not_graded': return rowVal === this.$p.t('abgabetool/c4notYetGraded');
+ case 'not_submitted':return rowVal === this.$p.t('abgabetool/c4notSubmitted');
+ case 'not_happened': return rowVal === this.$p.t('abgabetool/c4notHappenedYet');
+ case 'no_termin': return rowVal === this.$p.t('abgabetool/c4keinTerminVorhanden');
+ default: return true;
+ }
+ };
+
+ // OR logic — row passes if it matches any selected filter
+ return filterVal.some(val => matches(val));
+ },
+ getNotenFilterOptions(field) {
+ // mapping evaluated at render time, not at column definition time
+ const fieldOptionsMap = {
+ 'pa_note': this.notenOptions,
+ 'note': this.allowedNotenOptions,
+ };
+ const options = fieldOptionsMap[field] ?? this.notenOptions;
+
+ return (options ?? []).map(opt => ({ value: opt.note, label: opt.bezeichnung }));
+ },
+ notenHeaderFilterEditor(cell, onRendered, success, cancel) {
+ return multiSelectHeaderFilter(cell, onRendered, success, cancel, {
+ options: () => this.getNotenFilterOptions(cell.getField()),
+ selected: this.headerFilterSelection(cell.getField())
+ });
+ },
+ notenHeaderFilterFunc(filterVal, rowVal, rowData, filterParams) {
+ if (!filterVal || !filterVal.length) return true;
+ // columns showing the bezeichnung (note_bez) keep the note id in another field
+ const value = filterParams?.idField ? rowData?.[filterParams.idField] : rowVal;
+ // rowVal is the raw integer note id or a note object
+ const noteId = typeof value === 'object' ? value?.note : value;
+ return filterVal.some(val => val == noteId); // loose equality: filter vals are numbers, noteId might be string
+ },
+ notenSorter(a, b, aRow, bRow, column, dir, sorterParams) {
+ const aData = aRow.getData()
+ const bData = bRow.getData()
+ return aData.note - bData.note
+ },
+ sortFuncTerminCol(a, b, aRow, bRow, column, dir, params) {
+ if (a === null || typeof a === "undefined") return 1;
+ if (b === null || typeof b === "undefined") return -1;
+
+ // try to handle the prev/next interpretation consistently
+ // can only make this wrong UX whise so whatever
+ if(column._column.field == 'prevTermin') {
+ return Math.abs(b.diffMs) - Math.abs(a.diffMs)
+ } else if (column._column.field == 'nextTermin') {
+ return Math.abs(a.diffMs) - Math.abs(b.diffMs)
+ }
+
+ // just in case someone reuses this
+ return Math.abs(b.diffMs) - Math.abs(a.diffMs)
+ },
+ headerFilterTerminCol(filterVal, rowVal) {
+ if (!rowVal || !rowVal.luxonDate || !rowVal.luxonDate.isValid) {
+ return false;
+ }
+
+ const rowDate = rowVal.luxonDate;
+
+ const toLuxon = (val) => {
+ if (!val) return null;
+ let dt;
+ if (val instanceof Date) {
+ dt = luxon.DateTime.fromJSDate(val);
+ } else if (typeof val === "string") {
+ dt = toViennaDate(val);
+ } else { // fallback
+ dt = luxon.DateTime.fromMillis(Number(val));
+ }
+
+ return dt.isValid ? dt : null;
+ };
+
+ const von = toLuxon(filterVal[0]);
+ const bis = toLuxon(filterVal[1]);
+
+ // specific day
+ if (von && !bis) {
+ return rowDate.hasSame(von, "day");
+ }
+
+ // range case
+ if (von && bis) {
+ return rowDate >= von.startOf("day") && rowDate <= bis.endOf("day");
+ }
+
+ return false
+ },
+ getDateStyleHtml(dateStyle) {
+ const iconMap = {
+ 'verspaetet': '',
+ 'verpasst': '',
+ 'abzugeben': '',
+ 'standard': '',
+ 'abgegeben': '',
+ 'beurteilungerforderlich': '',
+ 'bestanden': '',
+ 'nichtbestanden': '',
+ };
+ return iconMap[dateStyle] ?? '';
+ },
+ abgabeterminFormatter(cell, formatterParams, onRendered) {
+ const val = cell.getValue()
+ const dateStyle = val?.dateStyle ?? val
+
+
+ if(val) {
+ let icon = ''
+ switch(dateStyle) {
+ case 'verspaetet':
+ icon = ''
+ break
+ case 'verpasst':
+ icon = ''
+ break
+ case 'abzugeben':
+ icon = ''
+ break
+ case 'standard':
+ icon = ''
+ break
+ case 'abgegeben':
+ icon = ''
+ break
+ case 'beurteilungerforderlich':
+ icon = ''
+ break
+ case 'bestanden':
+ icon = ''
+ break
+ case 'nichtbestanden':
+ icon = ''
+ break
+ }
+
+ const typKurzbz = val.paabgabetyp_kurzbz ?? val.bezeichnung?.paabgabetyp_kurzbz
+ const bezeichnung = this.$p.t('abgabetool/c4paatyp' + typKurzbz)
+
+ if(formatterParams?.iconOnly) {
+ return '' +
+ '' +
+ '
'
+ }
+
+ return '' +
+ '' +
+ '
' +
+ '
'+bezeichnung+' - '+ this.formatDate(val.datum)+'
' +
+ '
'+
+ '
'
+
+ } else {
+ return ''
+ }
+
+ },
+ centeredTextFormatter(cell) {
+ const longForm = cell.getValue()
+ if(!longForm) return
+ const data = cell.getData()
+ const entry = Object.entries(data).find(entry => entry[1] == longForm)
+
+ // shortFormKey must have same keyname as longForm but with 'Short' appended
+ const shortForm = data[entry[0]+'Short']
+
+ if(shortForm && longForm) {
+ return `
+
+ ${longForm}
+
+
+ ${shortForm}
+
+
`;
+ } else {
+ return ''
+ }
+ },
+ pkzTextFormatter(cell) {
+ const val = cell.getValue()
+
+ return ''
+ },
+ shortLongTitleFormatter(cell, formatterParams, onRendered) {
+ const longForm = cell.getValue()
+ const shortForm = formatterParams?.shortForm
+
+ if(longForm && shortForm) {
+ return `
+ ${longForm}
+
+
+ ${shortForm}
+ `
+ } else {
+ return `
+ ${longForm}
+ `
+ }
+
+ },
+ toolTipFuncPrevTermin(e, cell, onRendered) {
+ const data = cell.getData();
+ if(!data.prevTermin) return ''
+ return this.mapDateStyleToTabulatorTooltip(data.prevTermin.dateStyle);
+ },
+ toolTipFuncNextTermin(e, cell, onRendered) {
+ const data = cell.getData();
+ if(!data.nextTermin) return ''
+ return this.mapDateStyleToTabulatorTooltip(data.nextTermin.dateStyle);
+ },
+ mapDateStyleToTabulatorTooltip(dateStyleString) {
+ switch(dateStyleString) {
+ case 'bestanden':
+ return this.$p.t('abgabetool/c4tooltipBestanden')
+ break;
+ case 'nichtbestanden':
+ return this.$p.t('abgabetool/c4tooltipNichtBestanden')
+ break;
+ case 'beurteilungerforderlich':
+ return this.$p.t('abgabetool/c4tooltipBeurteilungerforderlich')
+ break;
+ case 'verspaetet':
+ return this.$p.t('abgabetool/c4tooltipVerspaetet')
+ break;
+ case 'abgegeben':
+ return this.$p.t('abgabetool/c4tooltipAbgegeben')
+ break;
+ case 'verpasst':
+ return this.$p.t('abgabetool/c4tooltipVerpasst')
+ break;
+ case 'abzugeben':
+ return this.$p.t('abgabetool/c4tooltipAbzugeben')
+ break;
+ case 'standard':
+ return this.$p.t('abgabetool/c4tooltipStandardv2')
+ break;
+ default: return ''
+ }
+ },
+ checkQualityGateStatus(projekt) {
+ const qgate1Termine = []
+ const qgate2Termine = []
+
+ projekt.qgate1Status = this.$p.t('abgabetool/c4keinTerminVorhanden')// 'Kein Termin vorhanden'
+ projekt.qgate1StatusRank = 0
+ projekt.qgate2Status = this.$p.t('abgabetool/c4keinTerminVorhanden')
+ projekt.qgate2StatusRank = 0
+
+ projekt.abgabetermine.forEach(termin => {
+ if(termin.paabgabetyp_kurzbz == 'qualgate1') qgate1Termine.push(termin)
+ if(termin.paabgabetyp_kurzbz == 'qualgate2') qgate2Termine.push(termin)
+ })
+
+ // calculate qgateStatusRank and display the highest order status rank of all quality gate termine until one
+ // counts as passed, which is just a positive note no matter if anything has been uploaded
+
+ // reuse luxon calculated diffMs (termin.datum in relation to today) from previous datestyle check
+ qgate1Termine.forEach(qgate => {
+ if(qgate.note != null && projekt.qgate1StatusRank <= 5) {
+ const noteOpt = typeof qgate.note !== 'object' ? this.notenOptions.find(opt => opt.note == qgate.note) : qgate.note
+ if(noteOpt.positiv) {
+ projekt.qgate1Status = this.$p.t('abgabetool/c4positivBenotet')
+ projekt.qgate1StatusRank = 5
+ } else {
+ projekt.qgate1Status = this.$p.t('abgabetool/c4negativBenotet')
+ projekt.qgate1StatusRank = 4
+ }
+ } else if (qgate.note == null && projekt.qgate1StatusRank <= 3) {
+ projekt.qgate1Status = this.$p.t('abgabetool/c4notYetGraded')
+ projekt.qgate1StatusRank = 3
+ } else if(qgate.upload_allowed == true && qgate.abgabedatum == null && projekt.qgate1StatusRank <= 2) {
+ projekt.qgate1Status = this.$p.t('abgabetool/c4notSubmitted')
+ projekt.qgate1StatusRank = 2
+ } else if (qgate.upload_allowed == false && qgate.diffMs <= 0 && projekt.qgate1StatusRank <= 1) {
+ projekt.qgate1Status = this.$p.t('abgabetool/c4notHappenedYet')
+ projekt.qgate1StatusRank = 1
+ }
+ })
+
+ qgate2Termine.forEach(qgate => {
+ if(qgate.note != null && projekt.qgate1StatusRank <= 5) {
+ const noteOpt = typeof qgate.note !== 'object' ? this.notenOptions.find(opt => opt.note == qgate.note) : qgate.note
+ if(noteOpt.positiv) {
+ projekt.qgate2Status = this.$p.t('abgabetool/c4positivBenotet')
+ projekt.qgate2StatusRank = 5
+ } else {
+ projekt.qgate2Status = this.$p.t('abgabetool/c4negativBenotet')
+ projekt.qgate2StatusRank = 4
+ }
+ } else if (qgate.note == null && projekt.qgate2StatusRank <= 3) {
+ projekt.qgate2Status = this.$p.t('abgabetool/c4notYetGraded')
+ projekt.qgate2StatusRank = 3
+ } else if(qgate.upload_allowed == true && qgate.abgabedatum == null && projekt.qgate2StatusRank <= 2) {
+ projekt.qgate2Status = this.$p.t('abgabetool/c4notSubmitted')
+ projekt.qgate2StatusRank = 2
+ } else if (qgate.upload_allowed == false && qgate.diffMs <= 0 && projekt.qgate2StatusRank <= 1) {
+ projekt.qgate2Status = this.$p.t('abgabetool/c4notHappenedYet')
+ projekt.qgate2StatusRank = 1
+ }
+ })
+
+ // set shorthand statuscode once real status has been determined
+ projekt.qgate1StatusShort = this.mapRankToShortStatus(projekt.qgate1StatusRank)
+ projekt.qgate2StatusShort = this.mapRankToShortStatus(projekt.qgate2StatusRank)
+ },
+ mapRankToShortStatus(rank) {
+ switch(rank){
+ case 0: // kein termin vorhanden
+ return '--'
+ break;
+ case 1: // noch nicht stattgefunden
+ return 'o'
+ break;
+ case 2: // noch nicht abgegeben
+ return '?'
+ break;
+ case 3: // noch nicht benotet
+ return '~'
+ break;
+ case 4: // negativ benotet
+ return '-'
+ break;
+ case 5: // positiv benotet
+ return '+'
+ break;
+ }
+ },
+ loadTableState(persistenceID) {
+ return JSON.parse(localStorage.getItem(persistenceID) || "null");
+ },
+ saveTableState(table, persistenceID, state) {
+ // avoid storing state after first restore part happened
+ if(!state.stateRestored) return
+ const rawLayout = table.getColumnLayout();
+ const savedState = {
+ columns: rawLayout.map(col => ({
+ field: col.field,
+ visible: col.visible,
+ width: col.width,
+ })),
+ sort: table.getSorters().map(s => ({
+ field: s.field,
+ dir: s.dir,
+ })),
+ filters: table.getFilters(),
+ headerFilters: table.getHeaderFilters()
+ };
+
+ localStorage.setItem(persistenceID, JSON.stringify(savedState));
+ },
+ // attaches the localStorage persistence (column layout, sorters, filters) to a table.
+ // state holds the restore flags of that table, since a component can hold multiple tables
+ initTablePersistence(tableCmpt, persistenceID, state) {
+ const table = tableCmpt.tabulator
+
+ Object.assign(state, {
+ stateRestored: false,
+ colLayoutRestored: false,
+ filtersRestored: false,
+ headerFiltersRestored: false,
+ sortRestored: false
+ })
+
+ const save = () => this.saveTableState(table, persistenceID, state);
+
+ table.on("columnMoved", save);
+ table.on("columnResized", save);
+ table.on("columnVisibilityChanged", save);
+ table.on("filterChanged", save);
+ table.on("headerFilterChanged", save);
+ table.on("dataSorted", save);
+ table.on("columnSorted", save);
+ table.on("sortersChanged", save);
+
+ const saved = this.loadTableState(persistenceID);
+
+ table.on("renderComplete", () => {
+ if(state.stateRestored) return
+
+ if (saved?.columns && !state.colLayoutRestored) {
+ const layout = saved.columns.map(col => ({
+ field: col.field,
+ width: col.width,
+ visible: col.visible,
+ // add more if needed, but keep it simple
+ }));
+
+ table.setColumnLayout(layout);
+
+ state.colLayoutRestored = true;
+ }
+
+ if (saved?.filters && !state.filtersRestored) {
+ state.filtersRestored = true // instantly avoid retriggers
+ table.setFilter(saved.filters);
+ }
+
+ if (saved?.headerFilters && !state.headerFiltersRestored) {
+ state.headerFiltersRestored = true // instantly avoid retriggers
+ for (let hf of saved.headerFilters) {
+ // keep the checkboxes of the multiselect header filters in sync with the restored value
+ if (Array.isArray(hf.value)) this.headerFilterSelections[hf.field] = [...hf.value];
+ table.setHeaderFilterValue(hf.field, hf.value);
+ }
+ }
+
+ if (saved?.sort?.length && !state.sortRestored) {
+ state.sortRestored = true;
+
+ setTimeout(() => {
+ const sortList = saved.sort.map(s => {
+ const col = table.columnManager.findColumn(s.field);
+ if (!col) {
+ return null;
+ }
+ return { column: col, dir: s.dir };
+ }).filter(Boolean);
+
+ table.setSort(sortList);
+ }, 100);
+ }
+ state.stateRestored = true
+
+ // ensure that the filterCollapseables thingy has the correct values
+ tableCmpt.setSelectedFields();
+ });
+ },
+ formatDate(dateParam) {
+ return formatISODate(dateParam);
+ },
+ getOptionLabelAbgabetyp(option){
+ return this.$p.t('abgabetool/c4paatyp' + option.paabgabetyp_kurzbz)
+ },
+ buildStg(projekt) {
+ return (projekt.typ + projekt.kurzbz)?.toUpperCase()
+ },
+ handleToggleFullscreenDetail() {
+ this.detailIsFullscreen = !this.detailIsFullscreen
+ },
+ openAddSeriesModal() {
+ this.$refs.modalContainerAddSeries.show()
+ },
+ tableResolve(resolve) {
+ this.tableBuiltResolve = resolve
+ },
+ handleUuidDefined(uuid) {
+ this.tabulatorUuid = uuid
+ }
+ }
+};
+
+export default AbgabetoolTableMixin;
diff --git a/public/js/tabulator/filters/multiSelectHeaderFilter.js b/public/js/tabulator/filters/multiSelectHeaderFilter.js
new file mode 100644
index 000000000..1478d5c82
--- /dev/null
+++ b/public/js/tabulator/filters/multiSelectHeaderFilter.js
@@ -0,0 +1,106 @@
+// Tabulator headerFilter editor: readonly input opening a checkbox list which is attached to the body,
+// so it is not clipped by the table header. Filter value is the array of selected option values.
+//
+// params:
+// options () => [{ value, label, badge: { cssClass, html } }] - resolved on every open, so
+// asynchronously loaded lists (noten, abgabetypen) are picked up even if they were
+// not available yet when the header filter was rendered
+// selected { get, set } - holds the selected values outside of the editor, since tabulator
+// rebuilds the editor on every column redraw
+// minWidth min width of the dropdown
+export function multiSelectHeaderFilter(cell, onRendered, success, cancel, params = {}) {
+ let selected = [...(params.selected?.get() ?? [])];
+ let options = [];
+
+ const wrapper = document.createElement('div');
+ wrapper.style.cssText = 'position: relative; width: 100%;';
+
+ const display = document.createElement('input');
+ display.readOnly = true;
+ display.placeholder = '';
+ display.style.cssText = 'padding: 4px; width: 100%; box-sizing: border-box; cursor: default; border: 1px solid; outline: none; background: #fff; appearance: none; caret-color: transparent;';
+
+ const dropdown = document.createElement('div');
+ dropdown.style.cssText = 'display: none; position: fixed; background: #fff; border: 1px solid; z-index: 9999; min-width: '
+ + (params.minWidth ?? '180px') + '; box-shadow: 0 2px 6px rgba(0,0,0,0.15);';
+
+ const updateDisplay = () => {
+ display.value = options
+ .filter(o => selected.includes(o.value))
+ .map(o => o.label)
+ .join(', ');
+ };
+
+ const buildOptionRow = (opt) => {
+ const row = document.createElement('label');
+ row.style.cssText = opt.badge
+ ? 'display: flex; align-items: center; gap: 0; cursor: pointer; white-space: nowrap; padding-right: 8px;'
+ : 'display: flex; align-items: center; gap: 6px; padding: 4px 8px; cursor: pointer; white-space: nowrap;';
+ row.addEventListener('mousedown', e => e.preventDefault());
+
+ const checkbox = document.createElement('input');
+ checkbox.type = 'checkbox';
+ checkbox.value = opt.value;
+ checkbox.checked = selected.includes(opt.value);
+ checkbox.style.cssText = 'margin: 0 6px;';
+ checkbox.addEventListener('change', () => {
+ selected = checkbox.checked
+ ? [...selected, opt.value]
+ : selected.filter(v => v !== opt.value);
+ params.selected?.set([...selected]);
+ updateDisplay();
+ success([...selected]);
+ });
+ row.appendChild(checkbox);
+
+ if (opt.badge) {
+ // icon badge — same look as the cell
+ const badge = document.createElement('div');
+ badge.className = opt.badge.cssClass;
+ badge.style.cssText = `min-width: 36px; height: 36px; display: flex; align-items: center;
+ justify-content: center; flex-shrink: 0; padding: 0px 17px 0px 17px;`;
+ badge.innerHTML = opt.badge.html;
+ row.appendChild(badge);
+ }
+
+ const labelText = document.createElement('span');
+ labelText.textContent = opt.label;
+ if (opt.badge) labelText.style.cssText = 'margin-left: 6px;';
+ row.appendChild(labelText);
+
+ return row;
+ };
+
+ const renderOptions = () => {
+ options = params.options?.() ?? [];
+ dropdown.replaceChildren(...options.map(buildOptionRow));
+ updateDisplay();
+ };
+
+ renderOptions();
+
+ display.addEventListener('click', () => {
+ if (dropdown.style.display === 'none') {
+ renderOptions();
+ const rect = display.getBoundingClientRect();
+ dropdown.style.top = rect.bottom + 'px';
+ dropdown.style.left = rect.left + 'px';
+ dropdown.style.display = 'block';
+ } else {
+ dropdown.style.display = 'none';
+ }
+ });
+
+ display.addEventListener('blur', () => {
+ setTimeout(() => { dropdown.style.display = 'none'; }, 150);
+ });
+
+ document.body.appendChild(dropdown);
+ wrapper.appendChild(display);
+ cell.getElement().addEventListener('remove', () => dropdown.remove());
+ onRendered(() => display.focus());
+
+ return wrapper;
+}
+
+export default multiSelectHeaderFilter;
diff --git a/system/phrasesupdate.php b/system/phrasesupdate.php
index 1335c3b72..90f5a36c7 100644
--- a/system/phrasesupdate.php
+++ b/system/phrasesupdate.php
@@ -46882,6 +46882,46 @@ array(
)
)
),
+ array(
+ 'app' => 'core',
+ 'category' => 'abgabetool',
+ 'phrase' => 'c4newUploadsNotification',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Neue Abgaben: Bachelor-/Masterarbeiten',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => "New submissions: Bachelor's-/Master's Thesis",
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
+ array(
+ 'app' => 'core',
+ 'category' => 'abgabetool',
+ 'phrase' => 'c4mehrereProjektarbeiten',
+ 'insertvon' => 'system',
+ 'phrases' => array(
+ array(
+ 'sprache' => 'German',
+ 'text' => 'Mehrere Projektarbeiten (siehe unten)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ ),
+ array(
+ 'sprache' => 'English',
+ 'text' => 'Several theses (see below)',
+ 'description' => '',
+ 'insertvon' => 'system'
+ )
+ )
+ ),
// PAABGABETYP PHRASEN BEGIN
array(
'app' => 'core',