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 = ' - - - - - - - - - - '; - - 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 .= " - - - - - - "; + if(!$betreuerArr) { + $this->_ci->logInfo('Could not load Betreuer PersonID: "'.$person_id.'".'); + continue; } - $abgabenString .= '
ZieldatumStudierende/rBezeichnungAbgabedatum
{$dateEmailFormatted}{$studentFullName} - {$abgabe->bezeichnung}{$kurzbzLine} - {$abgabedatumFormatted}
'; + $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 .= ' + + + + + + + + + '; + + 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 .= " + + + + + "; + } + + $abgabenString .= '
ZieldatumBezeichnungAbgabedatum
{$dateEmailFormatted} + {$abgabe->bezeichnung}{$kurzbzLine} + {$abgabedatumFormatted}
'; + } + + // 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} - - ` - } 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} - - -
`; - } else { - return '
' + - '

'+longForm+'

' - } - }, - pkzTextFormatter(cell) { - const val = cell.getValue() - - return '
' + - ''+val+'
' - }, - 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 '
' + - '
' + - icon + - '
' + - '
' - } - - return '
' + - '
' + - icon + - '
' + - '
' + - '

'+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} - - ` - } 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 '
' + - '
' + - icon + - '
' + - '
' + - '

'+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} - - -
`; - } else { - return '
' + - '

'+longForm+'

' - } - }, detailFormatter(cell) { return '
' + '
' }, - pkzTextFormatter(cell) { - const val = cell.getValue() - - return '
' + - ''+val+'
' - }, - 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 '
' + + '
' + + icon + + '
' + + '
' + } + + return '
' + + '
' + + icon + + '
' + + '
' + + '

'+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} + + +
`; + } else { + return '
' + + '

'+longForm+'

' + } + }, + pkzTextFormatter(cell) { + const val = cell.getValue() + + return '
' + + ''+val+'
' + }, + shortLongTitleFormatter(cell, formatterParams, onRendered) { + const longForm = cell.getValue() + const shortForm = formatterParams?.shortForm + + if(longForm && shortForm) { + return ` + ${longForm} + + ` + } 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',