mirror of
https://github.com/FH-Complete/FHC-Core.git
synced 2026-10-20 10:49:36 +00:00
check for schlagwoerter length in zusatzdaten frontend/backend - likely the cause of finished projects with missing data; extracted a lot more duplicate code across a abgabetool mixin; put the multiSelectHeaderFilter.js into tabulator/filters to reuse since 2 abgabetool pages already use it for 6 columns; checked over AbgabetoolJob.php again to make sure to avoid unnecessary automated emails + some logging;
This commit is contained in:
@@ -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 = '
|
||||
<table style="width: 100%; border-collapse: collapse; font-family: Arial, sans-serif; color: #333; margin-top: 15px; margin-bottom: 15px;">
|
||||
<thead>
|
||||
<tr style="background-color: #f2f2f2; text-align: left;">
|
||||
<th style="padding: 10px; border: 1px solid #ddd; font-size: 13px; width: 15%;">Zieldatum</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd; font-size: 13px; width: 25%;">Studierende/r</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd; font-size: 13px;">Bezeichnung</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd; font-size: 13px; width: 15%;">Abgabedatum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>';
|
||||
|
||||
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) ? "<br/><small style='color: #666; font-style: italic;'>{$abgabe->kurzbz}</small>" : "";
|
||||
|
||||
$abgabenString .= "
|
||||
<tr>
|
||||
<td style='padding: 10px; border: 1px solid #ddd; font-size: 13px; vertical-align: top;'>{$dateEmailFormatted}</td>
|
||||
<td style='padding: 10px; border: 1px solid #ddd; font-size: 13px; vertical-align: top;'>{$studentFullName}</td>
|
||||
<td style='padding: 10px; border: 1px solid #ddd; font-size: 13px;'>
|
||||
<strong>{$abgabe->bezeichnung}</strong>{$kurzbzLine}
|
||||
</td>
|
||||
<td style='padding: 10px; border: 1px solid #ddd; font-size: 13px; vertical-align: top;'>{$abgabedatumFormatted}</td>
|
||||
</tr>";
|
||||
if(!$betreuerArr) {
|
||||
$this->_ci->logInfo('Could not load Betreuer PersonID: "'.$person_id.'".');
|
||||
continue;
|
||||
}
|
||||
|
||||
$abgabenString .= '</tbody></table>';
|
||||
$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 = '<div style="font-family: Arial, sans-serif; color: #333;">';
|
||||
|
||||
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 .= "
|
||||
<div style='margin-top: 25px; padding: 12px; background-color: #f8f9fa; border-left: 4px solid #007bff; border-bottom: 1px solid #eee;'>
|
||||
<strong style='font-size: 16px; color: #0056b3;'>Projekt: {$projektarbeit_titel}</strong><br/>
|
||||
<div style='margin-top: 5px; font-size: 14px;'>
|
||||
<strong>Studierende/r:</strong> {$studentFullName}
|
||||
</div>
|
||||
<span style='color: #666; font-size: 12px;'>
|
||||
ID: {$projektarbeit_id} | Rolle: {$s->betreuerart_kurzbz} |
|
||||
Stg: {$s->stgtyp}{$s->stgkz} ({$s->studiensemester_kurzbz})
|
||||
</span>
|
||||
</div>";
|
||||
|
||||
// start table
|
||||
$abgabenString .= '
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 25px;">
|
||||
<thead>
|
||||
<tr style="background-color: #eee; text-align: left;">
|
||||
<th style="padding: 10px; border: 1px solid #ddd; font-size: 13px; width: 20%;">Zieldatum</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd; font-size: 13px;">Bezeichnung</th>
|
||||
<th style="padding: 10px; border: 1px solid #ddd; font-size: 13px; width: 20%;">Abgabedatum</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>';
|
||||
|
||||
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) ? "<br/><small style='color: #666; font-style: italic;'>{$abgabe->kurzbz}</small>" : "";
|
||||
|
||||
$abgabenString .= "
|
||||
<tr>
|
||||
<td style='padding: 10px; border: 1px solid #ddd; font-size: 13px; vertical-align: top;'>{$dateEmailFormatted}</td>
|
||||
<td style='padding: 10px; border: 1px solid #ddd; font-size: 13px;'>
|
||||
<strong>{$abgabe->bezeichnung}</strong>{$kurzbzLine}
|
||||
</td>
|
||||
<td style='padding: 10px; border: 1px solid #ddd; font-size: 13px; vertical-align: top;'>{$abgabedatumFormatted}</td>
|
||||
</tr>";
|
||||
}
|
||||
|
||||
$abgabenString .= '</tbody></table>';
|
||||
}
|
||||
|
||||
// close container
|
||||
$abgabenString .= '</div>';
|
||||
|
||||
// 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++;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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': '<i class="fa-solid fa-triangle-exclamation"></i>',
|
||||
'verpasst': '<i class="fa-solid fa-calendar-xmark"></i>',
|
||||
'abzugeben': '<i class="fa-solid fa-hourglass-half"></i>',
|
||||
'standard': '<i class="fa-solid fa-clock"></i>',
|
||||
'abgegeben': '<i class="fa-solid fa-paperclip"></i>',
|
||||
'beurteilungerforderlich': '<i class="fa-solid fa-list-check"></i>',
|
||||
'bestanden': '<i class="fa-solid fa-check"></i>',
|
||||
'nichtbestanden': '<i class="fa-solid fa-circle-exclamation"></i>',
|
||||
};
|
||||
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 `<span class="full-text" style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">
|
||||
${longForm}
|
||||
</span>
|
||||
<span class="short-text" style="font-weight: bold; display: none;">
|
||||
${shortForm}
|
||||
</span>`
|
||||
} else {
|
||||
return `<span class="full-text" style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">
|
||||
${longForm}
|
||||
</span>`
|
||||
}
|
||||
|
||||
},
|
||||
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 = '<i class="fa-solid fa-triangle-exclamation"></i>'
|
||||
break
|
||||
case 'verpasst':
|
||||
icon = '<i class="fa-solid fa-calendar-xmark"></i>'
|
||||
break
|
||||
case 'abzugeben':
|
||||
icon = '<i class="fa-solid fa-hourglass-half"></i>'
|
||||
break
|
||||
case 'standard':
|
||||
icon = '<i class="fa-solid fa-clock"></i>'
|
||||
break
|
||||
case 'abgegeben':
|
||||
icon = '<i class="fa-solid fa-paperclip"></i>'
|
||||
break
|
||||
case 'beurteilungerforderlich':
|
||||
icon = '<i class="fa-solid fa-list-check"></i>'
|
||||
break
|
||||
case 'bestanden':
|
||||
icon = '<i class="fa-solid fa-check"></i>'
|
||||
break
|
||||
case 'nichtbestanden':
|
||||
icon = '<i class="fa-solid fa-circle-exclamation"></i>'
|
||||
break
|
||||
}
|
||||
|
||||
const typKurzbz = val.paabgabetyp_kurzbz ?? val.bezeichnung?.paabgabetyp_kurzbz
|
||||
const bezeichnung = this.$p.t('abgabetool/c4paatyp' + typKurzbz)
|
||||
|
||||
return '<div style="display: flex; height: 100%">' +
|
||||
'<div class=' + val.dateStyle + "-header" + ' style="min-width:48px; height: 100%; padding: 0px; display: flex; align-items: center; justify-content: center;">' +
|
||||
icon +
|
||||
'</div>' +
|
||||
'<div style="margin-left: 4px;">' +
|
||||
'<p style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">'+bezeichnung+' - '+ this.formatDate(val.datum)+'</p>' +
|
||||
'</div>'+
|
||||
'</div>'
|
||||
|
||||
} 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 `<div style="display: flex; justify-content: start; align-items: center; height: 100%; width: 100%;">
|
||||
<span class="full-text" style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">
|
||||
${longForm}
|
||||
</span>
|
||||
<span class="short-text" style="font-weight: bold; display: none;">
|
||||
${shortForm}
|
||||
</span>
|
||||
</div>`;
|
||||
} else {
|
||||
return '<div style="display: flex; justify-content: start; align-items: center; height: 100%">' +
|
||||
'<p style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">'+longForm+'</p></div>'
|
||||
}
|
||||
},
|
||||
detailFormatter(cell) {
|
||||
return '<div style="display: flex; justify-content: start; align-items: center; height: 100%">' +
|
||||
'<a><i class="fa fa-folder-open" style="color:#00649C"></i></a></div>'
|
||||
},
|
||||
pkzTextFormatter(cell) {
|
||||
const val = cell.getValue()
|
||||
|
||||
return '<div style="display: flex; justify-content: start; align-items: center; height: 100%">' +
|
||||
'<a style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">'+val+'</a></div>'
|
||||
},
|
||||
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')
|
||||
+ ': <strong>' + (this.selectedcount || 0) + '</strong>'
|
||||
+ ' | '
|
||||
+ this.$p.t('global/gefiltert')
|
||||
+ ': '
|
||||
+ '<strong>' + (this.filteredcount || 0) + '</strong>'
|
||||
+ ' | '
|
||||
+ this.$p.t('global/gesamt')
|
||||
+ ': <strong>' + (this.count || 0) + '</strong>';
|
||||
},
|
||||
emailItems() {
|
||||
const menu = []
|
||||
|
||||
|
||||
@@ -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')
|
||||
+ ': <strong>' + (this.selectedcount || 0) + '</strong>'
|
||||
+ ' | '
|
||||
+ this.$p.t('global/gefiltert')
|
||||
+ ': '
|
||||
+ '<strong>' + (this.filteredcount || 0) + '</strong>'
|
||||
+ ' | '
|
||||
+ this.$p.t('global/gesamt')
|
||||
+ ': <strong>' + (this.count || 0) + '</strong>';
|
||||
}
|
||||
},
|
||||
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': '<i class="fa-solid fa-triangle-exclamation"></i>',
|
||||
'verpasst': '<i class="fa-solid fa-calendar-xmark"></i>',
|
||||
'abzugeben': '<i class="fa-solid fa-hourglass-half"></i>',
|
||||
'standard': '<i class="fa-solid fa-clock"></i>',
|
||||
'abgegeben': '<i class="fa-solid fa-paperclip"></i>',
|
||||
'beurteilungerforderlich': '<i class="fa-solid fa-list-check"></i>',
|
||||
'bestanden': '<i class="fa-solid fa-check"></i>',
|
||||
'nichtbestanden': '<i class="fa-solid fa-circle-exclamation"></i>',
|
||||
};
|
||||
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 = '<i style="color: #000000 !important;" class="fa-solid fa-triangle-exclamation"></i>'
|
||||
break
|
||||
case 'verpasst':
|
||||
icon = '<i style="color: #000000 !important;" class="fa-solid fa-calendar-xmark"></i>'
|
||||
break
|
||||
case 'abzugeben':
|
||||
icon = '<i style="color: #000000 !important;" class="fa-solid fa-hourglass-half"></i>'
|
||||
break
|
||||
case 'standard':
|
||||
icon = '<i style="color: #000000 !important;" class="fa-solid fa-clock"></i>'
|
||||
break
|
||||
case 'abgegeben':
|
||||
icon = '<i style="color: #000000 !important;" class="fa-solid fa-paperclip"></i>'
|
||||
break
|
||||
case 'beurteilungerforderlich':
|
||||
icon = '<i style="color: #000000 !important;" class="fa-solid fa-list-check"></i>'
|
||||
break
|
||||
case 'bestanden':
|
||||
icon = '<i style="color: #000000 !important;" class="fa-solid fa-check"></i>'
|
||||
break
|
||||
case 'nichtbestanden':
|
||||
icon = '<i style="color: #000000 !important;" class="fa-solid fa-circle-exclamation"></i>'
|
||||
break
|
||||
}
|
||||
|
||||
const typKurzbz = val.paabgabetyp_kurzbz ?? val.bezeichnung?.paabgabetyp_kurzbz
|
||||
const bezeichnung = this.$p.t('abgabetool/c4paatyp' + typKurzbz)
|
||||
|
||||
if(formatterParams?.iconOnly) {
|
||||
return '<div style="display: flex; height: 20px;">' +
|
||||
'<div class=' + dateStyle + "-header" + ' style="min-width:48px; height: 100%; padding: 0px; display: flex; align-items: center; justify-content: center;">' +
|
||||
icon +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
}
|
||||
|
||||
return '<div style="display: flex; height: 20px;">' +
|
||||
'<div class=' + dateStyle + "-header" + ' style="min-width:48px; height: 100%; padding: 0px; display: flex; align-items: center; justify-content: center;">' +
|
||||
icon +
|
||||
'</div>' +
|
||||
'<div style="margin-left: 4px;">' +
|
||||
'<p style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">'+bezeichnung+' - '+ this.formatDate(val.datum)+'</p>' +
|
||||
'</div>'+
|
||||
'</div>'
|
||||
|
||||
} 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 `<div style="display: flex; justify-content: start; align-items: center; height: 100%; width: 100%;">
|
||||
<span class="full-text" style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">
|
||||
${longForm}
|
||||
</span>
|
||||
<span class="short-text" style="font-weight: bold; display: none;">
|
||||
${shortForm}
|
||||
</span>
|
||||
</div>`;
|
||||
} else {
|
||||
return '<div style="display: flex; justify-content: start; align-items: center; height: 100%">' +
|
||||
'<p style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">'+longForm+'</p></div>'
|
||||
}
|
||||
},
|
||||
pkzTextFormatter(cell) {
|
||||
const val = cell.getValue()
|
||||
|
||||
return '<div style="display: flex; justify-content: start; align-items: center; height: 100%">' +
|
||||
'<a style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">'+val+'</a></div>'
|
||||
},
|
||||
shortLongTitleFormatter(cell, formatterParams, onRendered) {
|
||||
const longForm = cell.getValue()
|
||||
const shortForm = formatterParams?.shortForm
|
||||
|
||||
if(longForm && shortForm) {
|
||||
return `<span class="full-text" style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">
|
||||
${longForm}
|
||||
</span>
|
||||
<span class="short-text" style="font-weight: bold; display: none;">
|
||||
${shortForm}
|
||||
</span>`
|
||||
} else {
|
||||
return `<span class="full-text" style="max-width: 100%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; margin: 0px;">
|
||||
${longForm}
|
||||
</span>`
|
||||
}
|
||||
|
||||
},
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user