Compare commits

...
12 changed files with 1218 additions and 1687 deletions
@@ -22,6 +22,10 @@ use CI3_Events as Events;
class Abgabe extends FHCAPI_Controller
{
// col limits of lehre.tbl_projektarbeit, exceeding them aborts the zusatzdaten update and lead to unpublishable enduploads
const MAX_LEN_SCHLAGWOERTER = 150; // varchar(150)
const MAX_LEN_ABSTRACT = 5000; // text, capped to match the ui counter
const MAX_SEITENANZAHL = 32767; // smallint
/**
* Object initialization
@@ -323,6 +327,13 @@ class Abgabe extends FHCAPI_Controller
$this->checkProjektarbeitForFinishedStatus($projektarbeit_id);
// block uploads for quality gate termine that are already graded
$this->checkPaabgabeForGradedStatus($paabgabe_id);
// the posted typ is client state only - verify it against the row so a stale ui cannot push an
// endabgabe through here, which would mark the abgabe without ever writing the zusatzdaten
$this->checkPaabgabeTypForZwischenabgabe($paabgabe_id, $paabgabetyp_kurzbz);
// load the $student_uid by $projektarbeit_id so we dont need any post params
$this->load->model('education/Projektarbeit_model', 'ProjektarbeitModel');
$res = $this->ProjektarbeitModel->getStudentInfoForProjektarbeitId($projektarbeit_id);
@@ -335,11 +346,11 @@ class Abgabe extends FHCAPI_Controller
$data = getData($res)[0];
$student_uid = $data->uid;
$studiengang_kz = $data->studiengang_kz;
if(!$this->checkZuordnung($projektarbeit_id, getAuthUID(), $student_uid, $studiengang_kz)) {
$this->terminateWithError($this->p->t('abgabetool', 'c4noZuordnungBetreuerStudent', [getAuthUID(), $student_uid]));
}
$path = PAABGABE_PATH.$paabgabe_id.'_'.$student_uid.'.pdf';
if ((isset($_FILES) and isset($_FILES['file']) and ! $_FILES['file']['error'])) {
@@ -400,8 +411,10 @@ class Abgabe extends FHCAPI_Controller
$this->terminateWithError($this->p->t('global', 'wrongParameters'), 'general');
}
$this->checkZusatzdatenLengths($schlagwoerter, $schlagwoerter_en, $abstract, $abstract_en, $seitenanzahl);
$this->checkPaabgabeDeadline($paabgabe_id);
$this->checkProjektarbeitForFinishedStatus($projektarbeit_id);
// load the $student_uid by $projektarbeit_id so we dont need any post params
@@ -450,17 +463,25 @@ class Abgabe extends FHCAPI_Controller
$this->checkAbgabeSignatur($paabgabe, $projektarbeit->student_uid);
$signaturstatus = $paabgabe->signatur;
// update projektarbeit cols with zusatzdaten AND abgabedatum!
$this->ProjektarbeitModel->update($projektarbeit->projektarbeit_id, array(
'sprache' => $sprache,
'seitenanzahl' => $seitenanzahl,
'abgabedatum' => date('Y-m-d'),
'schlagwoerter_en' => $schlagwoerter_en,
'schlagwoerter' => $schlagwoerter,
'abstract' => $abstract,
'abstract_en' => $abstract_en
));
// update projektarbeit cols with zusatzdaten AND abgabedatum!
$updateRes = $this->ProjektarbeitModel->update($projektarbeit->projektarbeit_id, array(
'sprache' => $sprache,
'seitenanzahl' => $seitenanzahl,
'abgabedatum' => date('Y-m-d'),
'schlagwoerter_en' => $schlagwoerter_en,
'schlagwoerter' => $schlagwoerter,
'abstract' => $abstract,
'abstract_en' => $abstract_en
));
// abort before marking the abgabe - a failed update used to leave the projektarbeit without
// abgabedatum and zusatzdaten while the endupload counted as done, blocking the publication
if(isError($updateRes)) {
$this->logLib->logInfoDB(array('endupload projektarbeit update failed', getError($updateRes),
$projektarbeit_id, $paabgabe_id, getAuthUID(), getAuthPersonId()));
$this->terminateWithError($this->p->t('abgabetool', 'c4enduploadZusatzdatenSpeichernFehlgeschlagen'), 'general');
}
// update paabgabe datum
$res = $this->PaabgabeModel->update($paabgabe_id, array(
@@ -947,6 +968,16 @@ class Abgabe extends FHCAPI_Controller
$paabgabeArr = $this->getDataOrTerminateWithError($result, 'general');
$paabgabe = $paabgabeArr[0];
// resolve the insert/update user full names so the frontend info string ("saved at X by Y")
// renders immediately for freshly created/updated termine without needing a reload.
$this->load->model('person/Person_model', 'PersonModel');
if(!empty($paabgabe->insertvon)) {
$paabgabe->insertvon_fullname = getData($this->PersonModel->getFullName($paabgabe->insertvon));
}
if(!empty($paabgabe->updatevon)) {
$paabgabe->updatevon_fullname = getData($this->PersonModel->getFullName($paabgabe->updatevon));
}
// check if abgabe even has note
if($paabgabe->note) {
$this->load->model('education/Note_model', 'NoteModel');
@@ -1057,6 +1088,22 @@ class Abgabe extends FHCAPI_Controller
$this->terminateWithError($this->p->t('global', 'wrongParameters'), 'general');
}
// a termin with an upload or a note must keep its typ and datum.
// the table only hides those rows client side, so a student upload
// after the table was loaded would otherwise still let a zwischenabgabe be retyped to 'end'
$existingPaabgabe = $paabgabeArr[0];
if($existingPaabgabe->note !== null || $existingPaabgabe->abgabedatum !== null) {
if(isset($updateFields['paabgabetyp_kurzbz'])
&& $updateFields['paabgabetyp_kurzbz'] !== $existingPaabgabe->paabgabetyp_kurzbz) {
$this->terminateWithError($this->p->t('abgabetool', 'c4abgabetypAendernNichtErlaubt'));
}
if(isset($updateFields['datum']) && $updateFields['datum'] !== $existingPaabgabe->datum) {
$this->terminateWithError($this->p->t('abgabetool', 'c4datumAendernNichtErlaubt'));
}
}
$result = $this->PaabgabeModel->update(
$paabgabe_id,
array_merge($updateFields, [
@@ -1570,7 +1617,9 @@ class Abgabe extends FHCAPI_Controller
$this->terminateWithError($this->p->t('global', 'wrongParameters'), 'general');
}
$this->checkZusatzdatenLengths($schlagwoerter, $schlagwoerter_en, $abstract, $abstract_en, $seitenanzahl);
$this->load->model('education/Projektarbeit_model', 'ProjektarbeitModel');
@@ -1605,7 +1654,7 @@ class Abgabe extends FHCAPI_Controller
}
// update projektarbeit cols with zusatzdaten only
$this->ProjektarbeitModel->update($projektarbeit_id, array(
$updateRes = $this->ProjektarbeitModel->update($projektarbeit_id, array(
'sprache' => $sprache,
'seitenanzahl' => $seitenanzahl,
'schlagwoerter_en' => $schlagwoerter_en,
@@ -1614,6 +1663,9 @@ class Abgabe extends FHCAPI_Controller
'abstract_en' => $abstract_en
));
// dont report success on a failed update, otherwise a repair attempt silently changes nothing
$this->getDataOrTerminateWithError($updateRes, 'general');
$this->logLib->logInfoDB(array('zusatzdatenEditMitarbeiter', array(
'updatevon' => getAuthUID(),
'updateamum' => date('Y-m-d H:i:s')
@@ -1996,13 +2048,59 @@ class Abgabe extends FHCAPI_Controller
$data = getData($res)[0];
if($data->note !== NULL) {
// hardcode this error msg cause phrasen arent reliable and people keep bugging why the cant edit old entries they definitely shouldnt update
$message = $this->p->t('abgabetool','c4fehlerAktualitaetProjektarbeitv3');
if(strpos($message, "<<") === 0) { // phrase could not be loaded
$this->terminateWithError('Die Projektarbeit wurde bereits benotet, Sie dürfen deshalb keine weiteren Termine anlegen oder bearbeiten.', 'general');
} else {
$this->terminateWithError($message);
}
$this->terminateWithError($this->p->t('abgabetool','c4fehlerAktualitaetProjektarbeitv3'));
}
}
// validates the zusatzdaten against the column limits of lehre.tbl_projektarbeit.
private function checkZusatzdatenLengths($schlagwoerter, $schlagwoerter_en, $abstract, $abstract_en, $seitenanzahl) {
if(mb_strlen((string)$schlagwoerter) > self::MAX_LEN_SCHLAGWOERTER
|| mb_strlen((string)$schlagwoerter_en) > self::MAX_LEN_SCHLAGWOERTER) {
$this->terminateWithError($this->p->t('abgabetool', 'c4schlagwoerterZuLang', array(self::MAX_LEN_SCHLAGWOERTER)), 'general');
}
if(mb_strlen((string)$abstract) > self::MAX_LEN_ABSTRACT
|| mb_strlen((string)$abstract_en) > self::MAX_LEN_ABSTRACT) {
$this->terminateWithError($this->p->t('abgabetool', 'c4abstractZuLang', array(self::MAX_LEN_ABSTRACT)), 'general');
}
if(!is_numeric($seitenanzahl) || (int)$seitenanzahl < 1 || (int)$seitenanzahl > self::MAX_SEITENANZAHL) {
$this->terminateWithError($this->p->t('abgabetool', 'c4seitenanzahlUngueltig', array(self::MAX_SEITENANZAHL)), 'general');
}
}
// zwischenabgabe uploads must never touch an endabgabe termin, to avoid empty zusatzdaten
private function checkPaabgabeTypForZwischenabgabe($paabgabe_id, $posted_paabgabetyp_kurzbz) {
$this->load->model('education/Paabgabe_model', 'PaabgabeModel');
$res = $this->PaabgabeModel->load($paabgabe_id);
if(isError($res) || !hasData($res)) {
$this->terminateWithError($this->p->t('abgabetool', 'c4projektabgabeNichtGefunden'), 'general');
}
$paabgabe = getData($res)[0];
if($paabgabe->paabgabetyp_kurzbz === 'end') {
$this->terminateWithError($this->p->t('abgabetool', 'c4enduploadUeberZwischenabgabeNichtErlaubt'), 'general');
}
// typ changed between page load and upload -> the client would use the wrong upload path
if($paabgabe->paabgabetyp_kurzbz !== $posted_paabgabetyp_kurzbz) {
$this->terminateWithError($this->p->t('abgabetool', 'c4abgabeterminGeaendertNeuLaden'), 'general');
}
}
private function checkPaabgabeForGradedStatus($paabgabe_id) {
$this->load->model('education/Paabgabe_model', 'PaabgabeModel');
$res = $this->PaabgabeModel->load($paabgabe_id);
if(isError($res) || !hasData($res)) {
$this->terminateWithError($this->p->t('abgabetool', 'c4projektabgabeNichtGefunden'), 'general');
}
$paabgabe = getData($res)[0];
if($paabgabe->note !== NULL) {
$this->terminateWithError($this->p->t('abgabetool', 'c4studentAbgabeNotAllowedRegularv3'));
}
}
+153 -73
View File
@@ -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++;
@@ -65,7 +65,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_projektarbeit.studiensemester_kurzbz,
public.tbl_person.anrede, public.tbl_person.titelpre, public.tbl_person.vorname, public.tbl_person.nachname, public.tbl_person.titelpost
@@ -89,7 +90,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_projektarbeit.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));
}
}
@@ -442,7 +442,12 @@ export const AbgabeMitarbeiterDetail = {
return true
},
allowedToSaveZusatzdaten() {
return this.form.schlagwoerter.length > 0 && this.form.schlagwoerter_en.length > 0 && this.form.abstract.length > 0 && this.form.abstract_en.length > 0 && this.form.seitenanzahl > 0
// upper bounds mirror the column limits of lehre.tbl_projektarbeit, see Abgabe.php
return this.form.schlagwoerter.length > 0 && this.form.schlagwoerter.length <= 150
&& this.form.schlagwoerter_en.length > 0 && this.form.schlagwoerter_en.length <= 150
&& this.form.abstract.length > 0 && this.form.abstract.length <= 5000
&& this.form.abstract_en.length > 0 && this.form.abstract_en.length <= 5000
&& this.form.seitenanzahl > 0 && this.form.seitenanzahl <= 32767
},
getAllowedAbgabeTypeOptions() {
if(this.assistenzMode) {
@@ -1003,14 +1008,16 @@ export const AbgabeMitarbeiterDetail = {
<div class="row mb-3 align-items-start">
<div class="row">{{$capitalize( $p.t('abgabetool/c4schlagwoerterGer') )}}</div>
<div class="row">
<Textarea v-model="form.schlagwoerter" class="w-100"></Textarea>
<Textarea v-model="form.schlagwoerter" maxlength="150" class="w-100"></Textarea>
<p>{{ form.schlagwoerter?.length ? form.schlagwoerter.length : 0 }} / 150 characters</p>
</div>
</div>
<div class="row mb-3 align-items-start">
<div class="row">{{$capitalize( $p.t('abgabetool/c4schlagwoerterEng') )}}</div>
<div class="row">
<Textarea v-model="form.schlagwoerter_en" class="w-100"></Textarea>
<Textarea v-model="form.schlagwoerter_en" maxlength="150" class="w-100"></Textarea>
<p>{{ form.schlagwoerter_en?.length ? form.schlagwoerter_en.length : 0 }} / 150 characters</p>
</div>
</div>
@@ -1035,7 +1042,7 @@ export const AbgabeMitarbeiterDetail = {
<div class="row">
<InputNumber
v-model="form.seitenanzahl"
inputId="seitenanzahlInput" :min="1" :max="100000">
inputId="seitenanzahlInput" :min="1" :max="32767">
</InputNumber>
</div>
</div>
@@ -258,7 +258,9 @@ export const AbgabeStudentDetail = {
return false;
}
if(termin.bezeichnung?.paabgabetyp_kurzbz === 'end') {
// use the field from the backend, not the client side mapped bezeichnung object - a failed
// lookup there would silently route an endupload through the zwischenabgabe endpoint
if(termin.paabgabetyp_kurzbz === 'end') {
this.enduploadTermin = termin
this.$refs.modalContainerEnduploadZusatzdaten.show()
} else {
@@ -306,6 +308,22 @@ export const AbgabeStudentDetail = {
const noteOpt = this.notenOptions.find(opt => opt.note == termin.note)
return noteOpt ? noteOpt.bezeichnung : ''
},
hasZweitbetreuer(projektarbeit) {
return !!(projektarbeit.zweitbetreuer_person_id || projektarbeit.zweitbetreuer)
},
getErstbetreuerLabel(projektarbeit) {
return projektarbeit.betreuerart_kurzbz
? this.$capitalize(this.$p.t('abgabetool/c4betrart' + projektarbeit.betreuerart_kurzbz))
: this.$capitalize(this.$p.t('abgabetool/c4betreuerv2'))
},
getZweitbetreuerLabel(projektarbeit) {
return projektarbeit.zweitbetreuer_betreuerart_kurzbz
? this.$capitalize(this.$p.t('abgabetool/c4betrart' + projektarbeit.zweitbetreuer_betreuerart_kurzbz))
: this.$capitalize(this.$p.t('abgabetool/c4zweitbetreuerv2'))
},
getZweitbetreuerName(projektarbeit) {
return projektarbeit.zweitbetreuer?.first ?? ''
},
},
watch: {
projektarbeit(newVal) {
@@ -349,7 +367,12 @@ export const AbgabeStudentDetail = {
return this.$capitalize(this.$p.t('abgabetool/c4eidesstattlicheErklaerung'))
},
allowedToSaveZusatzdaten() {
return this.form.schlagwoerter.length > 0 && this.form.schlagwoerter_en.length > 0 && this.form.abstract.length > 0 && this.form.abstract_en.length > 0 && this.form.seitenanzahl > 0
// upper bounds mirror the column limits of lehre.tbl_projektarbeit, see Abgabe.php
return this.form.schlagwoerter.length > 0 && this.form.schlagwoerter.length <= 150
&& this.form.schlagwoerter_en.length > 0 && this.form.schlagwoerter_en.length <= 150
&& this.form.abstract.length > 0 && this.form.abstract.length <= 5000
&& this.form.abstract_en.length > 0 && this.form.abstract_en.length <= 5000
&& this.form.seitenanzahl > 0 && this.form.seitenanzahl <= 32767
},
getAllowedToSendEndupload() {
return this.eidAkzeptiert && this.allowedToSaveZusatzdaten
@@ -392,7 +415,7 @@ export const AbgabeStudentDetail = {
if(this.isViewMode) {
return { value: this.$capitalize(this.$p.t('abgabetool/c4studentAbgabeNotAllowedInViewMode')), class: "custom-tooltip" }
} else {
return { value: this.$capitalize(this.$p.t('abgabetool/c4studentAbgabeNotAllowedRegularv2')), class: "custom-tooltip" }
return { value: this.$capitalize(this.$p.t('abgabetool/c4studentAbgabeNotAllowedRegularv3')), class: "custom-tooltip" }
}
},
getTooltipBeurteilungerforderlich() {
@@ -430,7 +453,8 @@ export const AbgabeStudentDetail = {
</button>
</p>
<p>{{$capitalize( $p.t('abgabetool/c4betreuerv2') ) }}: {{projektarbeit ? $p.t('abgabetool/c4betrart' + projektarbeit.betreuerart_kurzbz) + ' ' + projektarbeit.betreuer : ''}}</p>
<p>{{ getErstbetreuerLabel(projektarbeit) }}: {{ projektarbeit.betreuer }}</p>
<p v-if="hasZweitbetreuer(projektarbeit)">{{ getZweitbetreuerLabel(projektarbeit) }}: {{ getZweitbetreuerName(projektarbeit) }}</p>
</div>
<div class="col-4">
<div class="row">
@@ -716,14 +740,16 @@ export const AbgabeStudentDetail = {
<div class="row mb-3 align-items-start">
<div class="row">{{$capitalize( $p.t('abgabetool/c4schlagwoerterGer') )}}</div>
<div class="row">
<Textarea v-model="form.schlagwoerter" class="w-100"></Textarea>
<Textarea v-model="form.schlagwoerter" maxlength="150" class="w-100"></Textarea>
<p>{{ form.schlagwoerter?.length ? form.schlagwoerter.length : 0 }} / 150 characters</p>
</div>
</div>
<div class="row mb-3 align-items-start">
<div class="row">{{$capitalize( $p.t('abgabetool/c4schlagwoerterEng') )}}</div>
<div class="row">
<Textarea v-model="form.schlagwoerter_en" class="w-100"></Textarea>
<Textarea v-model="form.schlagwoerter_en" maxlength="150" class="w-100"></Textarea>
<p>{{ form.schlagwoerter_en?.length ? form.schlagwoerter_en.length : 0 }} / 150 characters</p>
</div>
</div>
@@ -748,7 +774,7 @@ export const AbgabeStudentDetail = {
<div class="row">
<InputNumber
v-model="form.seitenanzahl"
inputId="seitenanzahlInput" :min="1" :max="100000">
inputId="seitenanzahlInput" :min="1" :max="32767">
</InputNumber>
</div>
</div>
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 = []
@@ -187,7 +187,9 @@ export const AbgabetoolStudent = {
termin.file = []
termin.allowedToUpload = false
if(termin.paabgabetyp_kurzbz == 'end') {
if(termin.note) { // quality gate termin already has note
termin.allowedToUpload = false
} else if(termin.paabgabetyp_kurzbz == 'end') {
const inTime = termin.fixtermin ? !this.isPastDate(termin.datum) : true
termin.allowedToUpload = inTime && this.checkQualityGatesOptional(pa.abgabetermine)
} else if(termin.fixtermin) {
@@ -308,6 +310,19 @@ export const AbgabetoolStudent = {
hasZweitbetreuer(projektarbeit) {
return !!(projektarbeit.zweitbetreuer_person_id || projektarbeit.zweitbetreuer)
},
getErstbetreuerLabel(projektarbeit) {
return projektarbeit.betreuerart_kurzbz
? this.$capitalize(this.$p.t('abgabetool/c4betrart' + projektarbeit.betreuerart_kurzbz))
: this.$capitalize(this.$p.t('abgabetool/c4betreuerv2'))
},
getZweitbetreuerLabel(projektarbeit) {
return projektarbeit.zweitbetreuer_betreuerart_kurzbz
? this.$capitalize(this.$p.t('abgabetool/c4betrart' + projektarbeit.zweitbetreuer_betreuerart_kurzbz))
: this.$capitalize(this.$p.t('abgabetool/c4zweitbetreuerv2'))
},
getZweitbetreuerName(projektarbeit) {
return projektarbeit.zweitbetreuer?.first ?? ''
},
getErstbetreuerEmailLabel(projektarbeit) {
// with only one betreuer keep the generic label; with multiple betreuer
// distinguish the email rows by their betreuungsart bezeichnung
@@ -510,9 +525,9 @@ export const AbgabetoolStudent = {
</div>
</div>
<div class="row mt-2">
<div class="col-4 col-md-3 fw-bold">{{ projektarbeit?.betreuerart_kurzbz ? $capitalize( $p.t('abgabetool/c4betrart' + projektarbeit.betreuerart_kurzbz) ) : $capitalize( $p.t('abgabetool/c4betreuerv2') ) }}</div>
<div class="col-4 col-md-3 fw-bold">{{ getErstbetreuerLabel(projektarbeit) }}</div>
<div class="col-8 col-md-9">
{{ projektarbeit.betreuerart_kurzbz ? projektarbeit.betreuer : '' }}
{{ projektarbeit.betreuer }}
</div>
</div>
<div class="row mt-2">
@@ -521,13 +536,13 @@ export const AbgabetoolStudent = {
<a :href="getMailLink(projektarbeit)"><i class="fa fa-envelope" style="color:#00649C"></i></a>
</div>
</div>
<div v-if="projektarbeit.zweitbetreuer_person_id || projektarbeit.zweitbetreuer" class="row mt-2">
<div class="col-4 col-md-3 fw-bold">{{ projektarbeit.zweitbetreuer_betreuerart_kurzbz ? $p.t('abgabetool/c4betrart' + projektarbeit.zweitbetreuer_betreuerart_kurzbz) : '' }}</div>
<div v-if="hasZweitbetreuer(projektarbeit)" class="row mt-2">
<div class="col-4 col-md-3 fw-bold">{{ getZweitbetreuerLabel(projektarbeit) }}</div>
<div class="col-8 col-md-9">
{{ projektarbeit.zweitbetreuer?.first }}
{{ getZweitbetreuerName(projektarbeit) }}
</div>
</div>
<div v-if="projektarbeit.zweitbetreuer_person_id || projektarbeit.zweitbetreuer" class="row mt-2">
<div v-if="hasZweitbetreuer(projektarbeit)" class="row mt-2">
<div class="col-4 col-md-3 fw-bold">{{ getZweitbetreuerEmailLabel(projektarbeit) }}</div>
<div class="col-8 col-md-9">
<a v-if="projektarbeit.zweitbetreuer_mail" :href="getZweitbetreuerMailLink(projektarbeit)"><i class="fa fa-envelope" style="color:#00649C"></i></a>
@@ -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;
+163 -3
View File
@@ -46565,7 +46565,7 @@ array(
array(
'app' => 'core',
'category' => 'abgabetool',
'phrase' => 'c4studentAbgabeNotAllowedRegularv2',
'phrase' => 'c4studentAbgabeNotAllowedRegularv3',
'insertvon' => 'system',
'phrases' => array(
array(
@@ -46574,7 +46574,7 @@ array(
Um einen Endupload durchführen zu können, müssen Sie ein positiv benotetes Quality Gate 1 & Quality Gate 2 in der relevanten Projektarbeit absolviert haben.
Uploads sind für bereits benotete Projektarbeiten gesperrt. Sollten Sie trotzdem etwas hochladen wollen, wenden Sie sich bitte an Ihre Studiengangsassistenz.",
Uploads sind für bereits benotete Projektarbeiten beziehungsweise Quality Gates gesperrt. Sollten Sie trotzdem etwas hochladen wollen, wenden Sie sich bitte an Ihre Studiengangsassistenz.",
'description' => '',
'insertvon' => 'system'
),
@@ -46584,7 +46584,7 @@ array(
To be able to complete a final upload, you must have successfully completed Quality Gate 1 and Quality Gate 2 for the relevant project work.
Uploads are blocked for already graded project work. If you still wish to upload something, please contact your program assistant.',
Uploads are blocked for already graded project work aswell as quality gates. If you still wish to upload something, please contact your program assistant.',
'description' => '',
'insertvon' => 'system'
)
@@ -47709,6 +47709,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',
@@ -48895,6 +48935,126 @@ array(
)
)
),
array(
'app' => 'core',
'category' => 'abgabetool',
'phrase' => 'c4schlagwoerterZuLang',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Schlagwörter dürfen maximal {0} Zeichen lang sein.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Keywords may not exceed {0} characters.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'abgabetool',
'phrase' => 'c4abstractZuLang',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Der Abstract darf maximal {0} Zeichen lang sein.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The abstract may not exceed {0} characters.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'abgabetool',
'phrase' => 'c4seitenanzahlUngueltig',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Seitenanzahl muss zwischen 1 und {0} liegen.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The number of pages must be between 1 and {0}.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'abgabetool',
'phrase' => 'c4enduploadZusatzdatenSpeichernFehlgeschlagen',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Zusatzdaten konnten nicht gespeichert werden, der Upload wurde daher nicht abgeschlossen. Bitte wenden Sie sich an Ihre Studiengangsassistenz.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The additional data could not be saved, therefore the upload was not completed. Please contact your degree programme assistant.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'abgabetool',
'phrase' => 'c4enduploadUeberZwischenabgabeNichtErlaubt',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Dieser Termin ist eine Endabgabe und kann nur über den Endupload inklusive Zusatzdaten hochgeladen werden. Bitte laden Sie die Seite neu.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'This deadline is a final submission and can only be uploaded via the final upload including the additional data. Please reload the page.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'abgabetool',
'phrase' => 'c4abgabeterminGeaendertNeuLaden',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Der Abgabetermin wurde zwischenzeitlich geändert. Bitte laden Sie die Seite neu.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The submission deadline has been changed in the meantime. Please reload the page.',
'description' => '',
'insertvon' => 'system'
)
)
),
// ABGABETOOL PHRASEN END
array(
'app' => 'core',