Compare commits

...
24 Commits
Author SHA1 Message Date
Harald Bamberger e68634cf6f Merge branch 'feature-78270/StudVW_Abschlusspruefung_Eingabemodal_Uhrzeit_konfigurierbar' 2026-10-20 08:20:31 +02:00
Harald Bamberger e3ed6066c2 configure model-type on field uhrzeit timepicker and remove obsolete checkfunction, add translations for timepicker buttons, add uhrzeit to localstorage 2026-10-20 07:54:08 +02:00
Harald Bamberger 39ea868f08 Merge branch 'master' into feature-78270/StudVW_Abschlusspruefung_Eingabemodal_Uhrzeit_konfigurierbar 2026-10-16 15:59:05 +02:00
Harald Bamberger b2f3b5a3bc Merge branch 'bugfix-84655-bootstrap-modal-dispose-error' 2026-10-13 18:40:47 +02:00
adisposkofh 864b56e62c resolved error with modal dispose; removed dispose, issue with bubbling hide events in nested modals 2026-10-13 14:19:40 +02:00
Harald Bamberger 424ed0c5bb do not load Model from Extension in Core 2026-10-13 12:32:49 +02:00
Harald Bamberger b0f1572576 Merge branch 'cis40_2026-09_ma_rc' 2026-10-13 11:10:32 +02:00
Harald Bamberger 03efc55c93 remove checked in symlink that is generated by composer 2026-10-13 10:37:25 +02:00
Harald Bamberger 95c4dc2734 use Compat component for gesamtnote 2026-10-13 10:26:19 +02:00
Harald Bamberger 94c019888a use legacy link for gesamtnote for the moment 2026-10-13 09:50:04 +02:00
Harald Bamberger ed83b5e617 zeitsperren: add Mailphrases, mailstatus handling backend and frontend 2026-10-13 07:56:59 +02:00
Harald Bamberger 510ce22cc9 Merge branch 'cis40_2026-09_ma_rc_bugfix-83851-cms-content-table-formatting' into cis40_2026-09_ma_rc 2026-10-12 17:54:07 +02:00
Harald Bamberger 53049504a7 modify Zeitsperren API Endpoints to send sancho Mails, modify update check so that a zeitsperre does not block an update on itself when overlapping, add phrases for sancho mail subject to ci phrase system 2026-10-12 17:13:52 +02:00
Harald Bamberger 3e06740758 add permissions cis/eigene_zeitsperren and cis/andere_zeitsperren and use them for zeitsperren and zeitsperrenma applications and api endpoints 2026-10-12 14:25:31 +02:00
adisposkofh 73d22e14e5 cms content tabulator formatting fix 2026-10-12 12:39:43 +02:00
Harald Bamberger bca2884aa2 CisMenuApp fetch Authinfo, Cis search use isMitarbeiter flag to conditinally render Zeitsperren Button in search result 2026-10-12 11:37:45 +02:00
Harald Bamberger 63d0bfc69b Merge branch 'cis40_2026-09_ma_rc_bugfix-84008-lvplan-with-lvid-shows-reservations' into cis40_2026-09_ma_rc 2026-10-12 10:30:29 +02:00
Harald Bamberger ab05f32fde Merge branch 'cis40_2026-09_ma_rc' into cis40_2026-09_ma_rc_bugfix-84008-lvplan-with-lvid-shows-reservations 2026-10-12 10:21:03 +02:00
Harald Bamberger 62e704a6cf add berechtigung dashboard/widget_studstatus_ma, adapt dbupdate for widget studstatus to use new permission and use dashboard_kurzbz and widget_kurzbz to link widget to CIS dashboard 2026-10-12 09:37:37 +02:00
Harald Bamberger 2d5446bfc7 Merge branch 'feature-83700/RaumsucheStandort' into cis40_2026-09_ma_rc 2026-10-12 08:12:01 +02:00
adisposkofh cdd36f61b1 in lv plan not fetching reservations if lv id query param is present 2026-10-09 12:10:32 +02:00
Johann Hoffmann 221d810f3f raumsuche basic filter by reservieren & aktiv = true; added erweiterte filter and columns for m², stockwerk and gebäudeteil; added the standort category "Ohne Standort" for virtual ones; 2026-10-08 18:01:39 +02:00
Johann Hoffmann 9b137452d9 standort filter in raumsuche; fix the ort/standort queries so _DUMMY rooms actually get filtered out as intended 2026-10-01 17:21:55 +02:00
ma0068 756b9b995d add new Formfield uhrzeit in Tab Abschlusspruefung 2026-08-12 17:34:05 +02:00
31 changed files with 1448 additions and 88 deletions
+3
View File
@@ -74,6 +74,9 @@ $config['tabs'] =
],
],
],
'hiddenFields' => [
'uhrzeit'
]
],
'exemptions' => [
//if true, Anrechnungen can be added and edited in tab Anrechnungen
+1 -1
View File
@@ -7,7 +7,7 @@ class Zeitsperren extends Auth_Controller
public function __construct()
{
parent::__construct([
'index' => ['basis/cis:r'],
'index' => ['cis/eigene_zeitsperren:r'],
]);
// Load Libraries
@@ -13,7 +13,7 @@ class Zeitsperrenma extends Auth_Controller
public function __construct()
{
parent::__construct([
'index' => ['basis/cis:r']
'index' => ['cis/andere_zeitsperren:r']
]);
}
@@ -379,7 +379,8 @@ class LvMenu extends FHCAPI_Controller
'name'=>$this->p->t('lehre', 'gesamtnote'),
'phrase' => 'lehre/gesamtnote',
'c4_icon'=> base_url('skin/images/button_endnote.png'),
'c4_link'=> site_url('Cis/Benotungstool/'.urlencode($lvid).'/'.urlencode($angezeigtes_stsem))
'c4_link'=> base_url('cis.php/Cis/Compat/legacy/cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php?lvid='.urlencode($lvid).'&stsem='.urlencode($angezeigtes_stsem))
//'c4_link'=> site_url('Cis/Benotungstool/'.urlencode($lvid).'/'.urlencode($angezeigtes_stsem))
//'c4_link'=> base_url('cis/private/lehre/benotungstool/lvgesamtnoteverwalten.php?lvid='.urlencode($lvid).'&stsem='.urlencode($angezeigtes_stsem))
//'c4_link'=> base_url('benotungstool/lvgesamtnoteverwalten.php?lvid='.urlencode($lvid).'&stsem='.urlencode($angezeigtes_stsem))
);
@@ -7,14 +7,14 @@ class MaZeitsperren extends FHCAPI_Controller
public function __construct()
{
parent::__construct([
'getAllActiveZeitsperren' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'getAllZeitsperrenFixeMa' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'getAllZeitsperrenLector' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'getAllZeitsperrenOes' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'getZeitsperrenAss' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'getAllActiveZeitsperren' => ['cis/andere_zeitsperren:r', 'admin:r', 'assistenz:r'],
'getAllZeitsperrenFixeMa' => ['cis/andere_zeitsperren:r', 'admin:r', 'assistenz:r'],
'getAllZeitsperrenLector' => ['cis/andere_zeitsperren:r', 'admin:r', 'assistenz:r'],
'getAllZeitsperrenOes' => ['cis/andere_zeitsperren:r', 'admin:r', 'assistenz:r'],
'getZeitsperrenAss' => ['cis/andere_zeitsperren:r', 'admin:r', 'assistenz:r'],
'getStgLectors' => self::PERM_LOGGED,
'loadZeitsperrenLectorStg' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'loadZeitsperrenMa' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'loadZeitsperrenLectorStg' => ['cis/andere_zeitsperren:r', 'admin:r', 'assistenz:r'],
'loadZeitsperrenMa' => ['cis/andere_zeitsperren:r', 'admin:r', 'assistenz:r'],
'getDetailsMa' => self::PERM_LOGGED
]);
+123 -6
View File
@@ -25,7 +25,19 @@ if (! defined('BASEPATH')) exit('No direct script access allowed');
*/
class Ort extends FHCAPI_Controller
{
// advanced filters of getRooms: tbl_ort column => filter type, the same list as in Raumsuche.js.
// text: contains, case-insensitive; select: equals; range: <column>_min, <column>_max; flag: true; exists: not null
const ADVANCED_FILTERS = [
'ort_kurzbz' => 'text',
'bezeichnung' => 'text',
'planbezeichnung' => 'text',
'stockwerk' => 'range',
'gebteil' => 'select',
'm2' => 'range',
'lehre' => 'flag',
'content_id' => 'exists'
];
/**
* Object initialization
*/
@@ -37,7 +49,9 @@ class Ort extends FHCAPI_Controller
'getOrtKurzbzContent' => self::PERM_LOGGED,
'getRooms' => self::PERM_LOGGED,
'getTypes' => self::PERM_LOGGED,
'getRoomInfo' => self::PERM_LOGGED
'getRoomInfo' => self::PERM_LOGGED,
'getStandorte' => self::PERM_LOGGED,
'getAdvancedFilterOptions' => self::PERM_LOGGED
]);
$this->load->model('ressource/Ort_model', 'OrtModel');
@@ -57,6 +71,17 @@ class Ort extends FHCAPI_Controller
$this->form_validation->set_rules('datum','Datum','required');
$this->form_validation->set_rules('von','Uhrzeit Von','required|regex_match[/^[0-9]{2}:[0-9]{2}$/]');
$this->form_validation->set_rules('bis','Uhrzeit Bis','required|regex_match[/^[0-9]{2}:[0-9]{2}$/]');
$this->form_validation->set_rules('standort_id','Standort','regex_match[/^([0-9]+|none)$/]');
foreach (self::ADVANCED_FILTERS as $column => $type) {
if ($type == 'range') {
$this->form_validation->set_rules($column.'_min', $column, 'numeric');
$this->form_validation->set_rules($column.'_max', $column, 'numeric');
} elseif ($type == 'flag' || $type == 'exists') {
$this->form_validation->set_rules($column, $column, 'in_list[true]');
} else {
$this->form_validation->set_rules($column, $column, 'max_length[255]');
}
}
if($this->form_validation->run() == FALSE) {
$this->terminateWithValidationErrors($this->form_validation->error_array());
}
@@ -66,6 +91,7 @@ class Ort extends FHCAPI_Controller
$bis = $this->input->get('bis', TRUE);
$typ = $this->input->get('typ', TRUE);
$personenanzahl = $this->input->get('personenanzahl', TRUE);
$standort_id = $this->input->get('standort_id', TRUE);
$this->load->model('ressource/Mitarbeiter_model', 'MitarbeiterModel');
@@ -78,21 +104,33 @@ class Ort extends FHCAPI_Controller
$this->load->model('ressource/Ort_model', 'OrtModel');
$params = array();
$qry = "SELECT DISTINCT tbl_ort.*
$qry = "SELECT DISTINCT tbl_ort.*, tbl_standort.bezeichnung AS standort
FROM public.tbl_ort JOIN public.tbl_ortraumtyp USING(ort_kurzbz)
WHERE aktiv AND lehre AND ort_kurzbz NOT LIKE '\\\\_%'";
LEFT JOIN public.tbl_standort USING(standort_id)
WHERE aktiv AND reservieren";
if($typ) {
$params[] = $typ;
$qry.= "AND raumtyp_kurzbz = ?";
$qry.= " AND raumtyp_kurzbz = ?";
}
if($standort_id === 'none') { // rooms without a Standort
$qry.= " AND standort_id IS NULL";
} elseif($standort_id) {
$params[] = $standort_id;
$qry.= " AND standort_id = ?";
} else {
$qry.= " AND standort_id IS NOT NULL";
}
$qry.= $this->advancedFilterSql($params);
if(!$isMitarbeiter) { // students are only allowed to get a subset defined by config
$qry.= ' AND raumtyp_kurzbz IN ?';
$params[] = $this->config->item('roomtypes_student');
$this->addMeta('config', $this->config->item('roomtypes_student'));
}
$qry.= "AND (max_person>= ? OR max_person is null)";
$qry.= " AND (max_person>= ? OR max_person is null)";
$params[] = $personenanzahl;
$qry.=" AND ort_kurzbz NOT IN
@@ -130,6 +168,41 @@ class Ort extends FHCAPI_Controller
$this->terminateWithSuccess(getData($result));
}
/**
* Standorte of the rooms that pass the base filter of getRooms
*/
public function getStandorte()
{
$qry = "SELECT DISTINCT standort_id, tbl_standort.bezeichnung
FROM public.tbl_ort JOIN public.tbl_standort USING(standort_id)
WHERE aktiv AND reservieren
ORDER BY tbl_standort.bezeichnung";
$result = $this->OrtModel->execReadOnlyQuery($qry);
$this->terminateWithSuccess($this->getDataOrTerminateWithError($result));
}
/**
* Values of the select filters: column => distinct values of the rooms that pass the base filter of getRooms
*/
public function getAdvancedFilterOptions()
{
$options = [];
foreach (self::ADVANCED_FILTERS as $column => $type) {
if ($type != 'select')
continue;
$result = $this->OrtModel->execReadOnlyQuery("SELECT DISTINCT ".$column." AS value
FROM public.tbl_ort
WHERE aktiv AND reservieren AND ".$column." <> ''
ORDER BY ".$column);
$options[$column] = array_column($this->getDataOrTerminateWithError($result), 'value');
}
$this->terminateWithSuccess($options);
}
/**
* Gets a JSON body via HTTP POST and provides the parameters
*/
@@ -188,5 +261,49 @@ class Ort extends FHCAPI_Controller
$this->terminateWithSuccess($room);
}
//------------------------------------------------------------------------------------------------------------------
// Private methods
/**
* SQL conditions of the advanced filters in the GET parameters, adds their values to $params
*/
private function advancedFilterSql(&$params)
{
// no xss_clean: the values are bound, and it could change a select value so that it no longer equals the column
$sql = '';
foreach (self::ADVANCED_FILTERS as $column => $type) {
if ($type == 'range') {
$min = $this->input->get($column.'_min');
$max = $this->input->get($column.'_max');
if ($min !== null && $min !== '') {
$sql.= " AND tbl_ort.".$column." >= ?::numeric";
$params[] = $min;
}
if ($max !== null && $max !== '') {
$sql.= " AND tbl_ort.".$column." <= ?::numeric";
$params[] = $max;
}
continue;
}
$value = $this->input->get($column);
if ($value === null || $value === '')
continue;
if ($type == 'flag') {
$sql.= " AND tbl_ort.".$column;
} elseif ($type == 'exists') {
$sql.= " AND tbl_ort.".$column." IS NOT NULL";
} elseif ($type == 'select') {
$sql.= " AND tbl_ort.".$column." = ?";
$params[] = $value;
} else {
$sql.= " AND strpos(lower(tbl_ort.".$column."), lower(?)) > 0";
$params[] = $value;
}
}
return $sql;
}
}
@@ -7,14 +7,14 @@ class Zeitsperren extends FHCAPI_Controller
public function __construct()
{
parent::__construct([
'getZeitsperrenUser' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'getZeitsperrenUser' => ['cis/eigene_zeitsperren:r', 'admin:r', 'assistenz:r'],
'getTypenZeitsperren' => self::PERM_LOGGED,
'getTypenErreichbarkeit' => self::PERM_LOGGED,
'getStunden' => self::PERM_LOGGED,
'loadZeitsperre' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'add' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'update' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'delete' => ['basis/mitarbeiter:r', 'admin:r', 'assistenz:r'],
'loadZeitsperre' => ['cis/eigene_zeitsperren:r', 'admin:r', 'assistenz:r'],
'add' => ['cis/eigene_zeitsperren:rw', 'admin:rw', 'assistenz:rw'],
'update' => ['cis/eigene_zeitsperren:rw', 'admin:rw', 'assistenz:rw'],
'delete' => ['cis/eigene_zeitsperren:rw', 'admin:rw', 'assistenz:rw'],
]);
// Load Libraries
@@ -179,6 +179,21 @@ class Zeitsperren extends FHCAPI_Controller
);
$data = $this->getDataOrTerminateWithError($result);
if($zeitsperretyp_kurzbz == 'ZA')
{
$this->sendSanchoMailToVorgesetzten(
'Sancho_Content_Zeitausgleich',
array(
'beschreibung' =>$zeitsperretyp_kurzbz,
'vonDatum' => (new DateTime($vondatum))->format('d.m.Y'),
'bisDatum' => (new DateTime($bisdatum))->format('d.m.Y'),
'Saldo'=> ''
),
$this->p->t('zeitsperren', 'zeitausgleichNeu'),
$mitarbeiter_uid
);
}
$this->terminateWithSuccess($data);
}
@@ -285,9 +300,13 @@ class Zeitsperren extends FHCAPI_Controller
$zeitsperretyp_kurzbz = array_key_exists('zeitsperretyp_kurzbz', $post) ? $update['zeitsperretyp_kurzbz'] : $data->zeitsperretyp_kurzbz;
$result = $this->ZeitsperreModel->getSperreByDate($mitarbeiter_uid, $vondatum, $vonstunde, true);
$data = $this->getDataOrTerminateWithError($result);
$data = (array) $this->getDataOrTerminateWithError($result);
if(hasData($result))
$data = array_filter($data, function($zeitsperre) use ($zeitsperre_id) {
return $zeitsperre->zeitsperre_id != $zeitsperre_id;
});
if(count($data) > 0)
{
$this->terminateWithError($this->p->t('zeitsperren', 'error_existingZeitsperre', ['typ'=> current($data)->zeitsperretyp_kurzbz]), self::ERROR_TYPE_GENERAL);
}
@@ -309,6 +328,21 @@ class Zeitsperren extends FHCAPI_Controller
$data = $this->getDataOrTerminateWithError($result);
if($zeitsperretyp_kurzbz == 'ZA')
{
$this->sendSanchoMailToVorgesetzten(
'Sancho_Content_ZA_Aendern',
array(
'beschreibung' =>$zeitsperretyp_kurzbz,
'vonDatum' => (new DateTime($vondatum))->format('d.m.Y'),
'bisDatum' => (new DateTime($bisdatum))->format('d.m.Y'),
'Saldo'=> ''
),
$this->p->t('zeitsperren', 'zeitausgleichGeaendert'),
$mitarbeiter_uid
);
}
$this->terminateWithSuccess($data);
}
else
@@ -327,7 +361,8 @@ class Zeitsperren extends FHCAPI_Controller
$loggedInUser = getAuthUID();
$result = $this->ZeitsperreModel->load($zeitsperre_id);
$data = $this->getDataOrTerminateWithError($result);
$uid = current($data)->mitarbeiter_uid;
$zeitsperre = (is_array($data) && count($data) > 0) ? current($data) : null;
$uid = (!is_null($zeitsperre)) ? $zeitsperre->mitarbeiter_uid : '';
if($uid != $loggedInUser)
$this->terminateWithError($this->p->t('ui', 'noPermission'), self::ERROR_TYPE_GENERAL);
@@ -339,6 +374,21 @@ class Zeitsperren extends FHCAPI_Controller
if (isError($result)) {
$this->terminateWithError(getError($result), self::ERROR_TYPE_GENERAL);
}
if($zeitsperre->zeitsperretyp_kurzbz == 'ZA')
{
$res = $this->sendSanchoMailToVorgesetzten(
'Sancho_Mail_ZA_loeschen',
array(
'beschreibung' =>$zeitsperre->zeitsperretyp_kurzbz,
'vonDatum' => (new DateTime($zeitsperre->vondatum))->format('d.m.Y'),
'bisDatum' => (new DateTime($zeitsperre->bisdatum))->format('d.m.Y')
),
$this->p->t('zeitsperren', 'zeitausgleichGeloescht'),
$zeitsperre->mitarbeiter_uid
);
}
$this->terminateWithSuccess((getData($result) ?: []));
}
@@ -363,5 +413,57 @@ class Zeitsperren extends FHCAPI_Controller
return $tage <= 14;
}
protected function sendSanchoMailToVorgesetzten($sancho_vorlage_kurzbz,
$sancho_vorlage_data, $subject, $mauid)
{
$this->load->helper('hlp_sancho_helper');
$this->load->model('ressource/Mitarbeiter_model', 'MitarbeiterModel');
$this->load->model('person/Person_model', 'PersonModel');
$fullname = getData($this->PersonModel->getFullName($mauid));
$vorgesetzte = (array) getData($this->MitarbeiterModel->getVorgesetzte($mauid));
$to = array();
$vorgesetzte_namen = array();
$mailsent = false;
$msg = $this->p->t('zeitsperren', 'konnteKeinFreigabemailVersendetWerden');
if(count($vorgesetzte) > 0)
{
foreach($vorgesetzte as $vg)
{
$to[] = $vg->vorgesetzter .'@' . DOMAIN;
$vorgesetzter_fullname = getData($this->PersonModel->getFullName($vg->vorgesetzter));
$vorgesetzte_namen[] = $vorgesetzter_fullname;
}
$sancho_vorlage_data['vorgesetzter'] = implode(', ', $vorgesetzte_namen);
$sancho_vorlage_data['nameMitarbeiter'] = $fullname;
$mailsent = sendSanchoMail(
$sancho_vorlage_kurzbz,
$sancho_vorlage_data,
implode(',', $to),
$subject
);
if($mailsent)
{
$msg = $this->p->t('zeitsperren', 'VorgesetzteInformiert',
array('vorgesetzte' => $sancho_vorlage_data['vorgesetzter']));
}
else
{
$msg = $this->p->t('zeitsperren', 'fehlerBeimSendenAufgetreten',
array('vorgesetzte' => $sancho_vorlage_data['vorgesetzter']));
}
}
$res = (object) array(
'mailsent' => $mailsent,
'msg' => $msg
);
$this->addMeta('mailstatus', $res);
}
}
@@ -32,7 +32,6 @@ class Funktionen extends FHCAPI_Controller
]);
// Load models
$this->load->model('extensions/FHC-Core-Personalverwaltung/Api_model', 'ApiModel');
$this->load->model('ressource/Funktion_model', 'FunktionModel');
$this->load->model('person/Benutzerfunktion_model', 'BenutzerfunktionModel');
@@ -315,6 +315,7 @@ class Abschlusspruefung extends FHCAPI_Controller
'abschlussbeurteilung_kurzbz' => $formData['abschlussbeurteilung_kurzbz'],
'datum' => $formData['datum'], //TODO(Manu) check if minute format like FAS
'sponsion' => $formData['sponsion'],
'uhrzeit' => $formData['uhrzeit'],
'pruefer1' => $formData['pruefer1'],
'pruefer2' => $formData['pruefer2'],
'pruefer3' => $formData['pruefer3'],
@@ -387,6 +388,7 @@ class Abschlusspruefung extends FHCAPI_Controller
'abschlussbeurteilung_kurzbz' => $formData['abschlussbeurteilung_kurzbz'],
'datum' => $datum,
'sponsion' => $sponsion,
'uhrzeit' => $formData['uhrzeit'],
'pruefer1' => $pruefer1,
'pruefer2' => $pruefer2,
'pruefer3' => $pruefer3,
@@ -22,6 +22,7 @@ $includesArray = array(
'public/css/components/FilterComponent.css',
'public/css/components/Profil.css',
'public/css/components/FormUnderline.css',
'public/css/components/inViewTooltip.css',
'public/css/components/abgabetool/abgabe.css',
'public/css/Cis4/Cms.css',
'public/css/Cis4/Studium.css',
+81
View File
@@ -0,0 +1,81 @@
/* the panel of js/directives/inViewTooltip.js. place() sets its place, max width and max height
inside the viewport, so neither a scrolling container nor a modal body clips it */
.fhc-in-view-tooltip {
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary, #0d6efd);
position: fixed;
z-index: 1080;
display: flex;
flex-direction: column;
width: max-content;
border: 1px solid var(--bs-border-color, #dee2e6);
border-radius: 0.375rem;
background-color: var(--bs-body-bg, #fff);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
color: var(--bs-body-color, #212529);
font-size: 0.875rem;
font-weight: normal;
line-height: 1.5;
text-align: left;
}
/* the dark primary reaches 2:1 on the dark panel, the highlight 3.9:1 */
html.dark .fhc-in-view-tooltip {
--fhc-in-view-tooltip-progress: var(--fhc-blue-primary-highlight, #6ea8fe);
}
/* the body scrolls, so the progress border stays on the panel edge */
.fhc-in-view-tooltip-body {
min-height: 0;
overflow-y: auto;
padding: 0.75rem 1rem;
white-space: pre-line; /* the text keeps its line breaks */
}
/* the hover timer of a sticky panel: after a pause the border fills up clockwise, then the panel sticks.
The 2 px stroke covers the 1 px border and 1 px inside it */
.fhc-in-view-tooltip-progress {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
}
.fhc-in-view-tooltip-progress rect {
fill: none;
stroke: var(--fhc-in-view-tooltip-progress);
stroke-width: 2;
stroke-dasharray: var(--fhc-in-view-tooltip-perimeter);
stroke-dashoffset: var(--fhc-in-view-tooltip-perimeter);
}
.fhc-in-view-tooltip--holding .fhc-in-view-tooltip-progress rect {
animation: fhc-in-view-tooltip-hold var(--fhc-in-view-tooltip-hold-fill) ease-in var(--fhc-in-view-tooltip-hold-delay) forwards;
}
/* a click during the hover timer fills the rest of the border quickly */
.fhc-in-view-tooltip--pinned .fhc-in-view-tooltip-progress rect {
stroke-dashoffset: 0;
transition: stroke-dashoffset 0.15s linear;
}
@keyframes fhc-in-view-tooltip-hold {
to {
stroke-dashoffset: 0;
}
}
/* the ? icon of components/InViewHelp.js takes the color of its context, e.g. white in the blue
modal header. A class like text-muted overrides it */
.fhc-in-view-help-btn,
.fhc-in-view-help-btn:hover,
.fhc-in-view-help-btn:focus-visible,
.btn.fhc-in-view-help-btn:active {
color: inherit;
}
.fhc-in-view-help-btn {
line-height: 1;
text-decoration: none;
}
+18 -4
View File
@@ -23,11 +23,11 @@ export default {
params: { ort_kurzbz: ort_kurbz },
};
},
getRooms(datum, von, bis, typ, personenanzahl = 0) {
getRooms(datum, von, bis, typ, personenanzahl = 0, standort_id = null, advancedFilters = {}) {
return {
method: "get",
url: "/api/frontend/v1/Ort/getRooms",
params: { datum, von, bis, typ, personenanzahl },
method: 'get',
url: '/api/frontend/v1/Ort/getRooms',
params: { datum, von, bis, typ, personenanzahl, standort_id, ...advancedFilters }
};
},
getRoomTypes() {
@@ -46,4 +46,18 @@ export default {
},
};
},
getStandorte() {
return {
method: 'get',
url: '/api/frontend/v1/Ort/getStandorte',
params: { }
};
},
getAdvancedFilterOptions() {
return {
method: 'get',
url: '/api/frontend/v1/Ort/getAdvancedFilterOptions',
params: { }
};
}
};
+11 -1
View File
@@ -2,6 +2,8 @@ import CisMenu from "../../components/Cis/Menu.js";
import PluginsPhrasen from '../../plugins/Phrasen.js';
import Theme from "../../plugins/Theme.js";
import ApiAuthinfo from "../../api/factory/authinfo.js";
const app = Vue.createApp({
name: 'CisMenuApp',
components: {
@@ -9,11 +11,15 @@ const app = Vue.createApp({
},
data: () => ({
windowWidth: 0,
isStudent: null,
isMitarbeiter: null,
}),
provide() {
return {
isNarrow: Vue.computed(() => this.windowWidth < 992),
isMobile: Vue.computed(() => this.windowWidth < 767),
isStudent: Vue.computed(() => this.isStudent),
isMitarbeiter: Vue.computed(() => this.isMitarbeiter)
}
},
methods: {
@@ -21,8 +27,12 @@ const app = Vue.createApp({
this.windowWidth = window.innerWidth;
},
},
created() {
async created() {
this.windowWidth = window.innerWidth;
await this.$api.call(ApiAuthinfo.getAuthInfo()).then((res) => {
this.isMitarbeiter = res.data.isMitarbeiter;
this.isStudent = res.data.isStudent;
});
},
mounted() {
window.addEventListener("resize", this.handleWindowResize);
+11 -8
View File
@@ -103,13 +103,6 @@ export default {
keyboard: this.keyboard
});
},
//to avoid pagelocks if more than one instance
beforeUnmount() {
if (this.modal) {
this.modal.dispose();
this.modal = null;
}
},
popup(body, options, title, footer) {
const BsModal = this,
slots = {};
@@ -164,7 +157,17 @@ export default {
});
});
},
template: `<div ref="modal" class="bootstrap-modal modal" tabindex="-1" @[\`hide.bs.modal\`]="$emit('hideBsModal')" @[\`hidden.bs.modal\`]="$emit('hiddenBsModal')" @[\`hidePrevented.bs.modal\`]="$emit('hidePreventedBsModal')" @[\`show.bs.modal\`]="$emit('showBsModal')" @[\`shown.bs.modal\`]="$emit('shownBsModal')">
template: `
<div
ref="modal"
class="bootstrap-modal modal"
tabindex="-1"
@[\`hide.bs.modal\`]="$emit('hideBsModal', $event)"
@[\`hidden.bs.modal\`]="$emit('hiddenBsModal', $event)"
@[\`hidePrevented.bs.modal\`]="$emit('hidePreventedBsModal', $event)"
@[\`show.bs.modal\`]="$emit('showBsModal', $event)"
@[\`shown.bs.modal\`]="$emit('shownBsModal', $event)"
>
<div class="modal-dialog" :class="fullscreen ? 'modal-fullscreen' : dialogClass">
<div class="modal-content">
<div v-if="$slots.title" class="modal-header" :class="headerClass">
@@ -67,7 +67,7 @@ export default {
new Tabulator(table, {
index: index,
layout: "fitDataFill",
layout: "fitColumns",
columnDefaults: {
formatter: "html",
@@ -68,7 +68,7 @@ export default {
new Tabulator(table, {
index: index,
layout: "fitDataFill",
layout: "fitColumns",
columnDefaults: {
formatter: "html",
@@ -74,7 +74,7 @@ export default {
for (let table of tables) {
this.sanitizeLegacyTables(table)
new Tabulator(table, {
layout: "fitDataStretch",
layout: "fitColumns",
columnDefaults: {
formatter: "html",
@@ -83,10 +83,26 @@ export default {
});
},
getPromiseFunc(start, end) {
return [
this.$api.call(ApiLvPlan.eventsLv(this.propsViewData.lv_id, start.toISODate(), end.toISODate())),
this.$api.call(ApiLvPlan.getLvPlanReservierungen(start.toISODate(), end.toISODate()))
let functions = [
this.$api.call(
ApiLvPlan.eventsLv(
this.propsViewData.lv_id,
start.toISODate(),
end.toISODate(),
),
),
];
if (!this.$props.propsViewData.lv_id?.length) {
functions.push(
this.$api.call(
ApiLvPlan.getLvPlanReservierungen(
start.toISODate(),
end.toISODate(),
),
),
);
}
return functions;
}
},
created() {
+5 -1
View File
@@ -65,6 +65,10 @@ export default {
label: Vue.computed(() => this.$p.t("profil/zeitsperren")),
icon: "fas fa-calendar-days",
type: "link",
isMitarbeiter: Vue.computed(() => this.isMitarbeiter),
renderif: function() {
return this.isMitarbeiter;
},
action: function (data) {
const uid = JSON.parse(data.data).uid;
const link =
@@ -163,7 +167,7 @@ export default {
openMenuHierarchy: [],
};
},
inject: ["isNarrow", "isMobile"],
inject: ["isNarrow", "isMobile", "isMitarbeiter", "isStudent"],
provide(){
return{
setActiveEntry: this.setActiveEntry,
+163 -28
View File
@@ -2,12 +2,39 @@ import {CoreFilterCmpt} from "../../../components/filter/Filter.js";
import FormInput from "../../Form/Input.js";
import VueDatePicker from '../../vueDatepicker.js.php';
import ApiOrt from '../../../api/factory/ort.js'
import InViewHelp from '../../InViewHelp.js';
// advanced filters: tbl_ort column, filter type and label, the same list as Ort::ADVANCED_FILTERS
const ADVANCED_FILTERS = [
{ column: 'lehre', type: 'flag', label: 'rauminfo/nurLehrraeume', checked: true },
{ column: 'content_id', type: 'exists', label: 'rauminfo/nurMitInfoseite' },
{ column: 'ort_kurzbz', type: 'text', label: 'rauminfo/raum_kurzbz' },
{ column: 'bezeichnung', type: 'text', label: 'global/bezeichnung' },
{ column: 'planbezeichnung', type: 'text', label: 'rauminfo/raumnummer' },
{ column: 'stockwerk', type: 'range', label: 'rauminfo/stockwerk' },
{ column: 'gebteil', type: 'select', label: 'rauminfo/gebaeudeteil' },
{ column: 'm2', type: 'range', label: 'rauminfo/flaeche' }
];
// the GET parameters of the advanced filters with their default values
const ADVANCED_DEFAULTS = {};
for (const { column, type, checked } of ADVANCED_FILTERS) {
if (type === 'range') {
ADVANCED_DEFAULTS[column + '_min'] = '';
ADVANCED_DEFAULTS[column + '_max'] = '';
} else {
ADVANCED_DEFAULTS[column] = type === 'flag' || type === 'exists' ? !!checked : '';
}
}
export const Raumsuche = {
name: "Raumsuche",
components: {
VueDatePicker,
CoreFilterCmpt,
FormInput,
InViewHelp,
InputNumber: primevue.inputnumber,
},
inject: ["isMobile"],
data() {
@@ -18,13 +45,17 @@ export const Raumsuche = {
tableBuiltResolve: null,
tableBuiltPromise: null,
roomtypes: null,
defaultType: {
raumtyp_kurzbz: '',
beschreibung: Vue.computed(() => this.$p.t('global/alle'))
},
anzahl: 1,
anzahlInput: '1',
selectedType: null,
standorte: null,
standort_id: null,
roomsRequest: 0,
loading: false,
advancedFilterFields: ADVANCED_FILTERS,
advancedFilters: { ...ADVANCED_DEFAULTS },
advancedFilterOptions: {},
showAdvancedFilters: false,
datum: new Date(),
von: Vue.ref({
hours: new Date().getHours(),
@@ -57,7 +88,12 @@ export const Raumsuche = {
{title: Vue.computed(() => this.$p.t('rauminfo/raum_kurzbz')), field: 'ort_kurzbz', widthGrow: 1},
{title: Vue.computed(() => this.$p.t('global/bezeichnung')), field: 'bezeichnung', widthGrow: 2},
{title: Vue.computed(() => this.$p.t('rauminfo/raumnummer')), field: 'nummer', widthGrow: 1},
{title: Vue.computed(() => this.$p.t('rauminfo/standort')), field: 'standort', widthGrow: 1},
{title: Vue.computed(() => this.$p.t('rauminfo/gebaeudeteil')), field: 'gebteil', widthGrow: 1, visible: false},
{title: Vue.computed(() => this.$p.t('rauminfo/stockwerk')), field: 'stockwerk', sorter: 'number', widthGrow: 1, visible: false},
{title: Vue.computed(() => this.$p.t('rauminfo/personcap')), field: 'personen', widthGrow: 1},
{title: Vue.computed(() => this.$p.t('rauminfo/flaeche')), field: 'm2', sorter: 'number', widthGrow: 1, visible: false},
{title: Vue.computed(() => this.$p.t('rauminfo/lehrraum')), field: 'lehre', formatter: 'tickCross', hozAlign: 'center', widthGrow: 1, visible: false},
{title: Vue.computed(() => this.$p.t('rauminfo/rauminfo')),
field: 'linkInfo', formatter: this.linkFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$p.t('rauminfo/roomReservations')),
@@ -76,8 +112,21 @@ export const Raumsuche = {
computed: {
isDarkMode(){
return this.$theme.theme_name.value == 'dark';
},
// shown on the toggle, the panel can be closed while its filters are active
activeAdvancedFilters() {
return Object.keys(ADVANCED_DEFAULTS).filter(key => this.advancedFilters[key] !== ADVANCED_DEFAULTS[key]).length
}
},
watch: {
datum: 'loadRooms',
von: 'loadRooms',
bis: 'loadRooms',
selectedType: 'loadRooms',
standort_id: 'loadRooms',
anzahl: 'loadRooms',
advancedFilters: { handler: 'loadRooms', deep: true }
},
methods: {
tableResolve(resolve) {
this.tableBuiltResolve = resolve
@@ -117,7 +166,12 @@ export const Raumsuche = {
ort_kurzbz: room.ort_kurzbz,
bezeichnung: room.bezeichnung.replace('&amp;', '&'),
nummer: room.planbezeichnung,
standort: room.standort,
gebteil: room.gebteil,
stockwerk: room.stockwerk,
personen: room.max_person,
m2: room.m2,
lehre: room.lehre,
linkInfo: room.content_id ? this.roomInfoLink(room) : null,
linkRes: this.roomPlanLink(room)
@@ -133,15 +187,34 @@ export const Raumsuche = {
res?.data?.forEach(type => {
type.beschreibung = type.beschreibung?.replace('&amp;', '&')
})
this.selectedType = this.defaultType
this.roomtypes = res?.data ?? []
})
},
loadRooms() {
this.$api.call(ApiOrt.getRooms(this.datum.toISOString(), this.getTimeString(this.von), this.getTimeString(this.bis), this.selectedType?.raumtyp_kurzbz ?? '', this.anzahl))
loadStandorte() {
this.$api.call(ApiOrt.getStandorte())
.then(res => {
if(res?.data?.retval) this.setupData(res.data.retval)
})
this.standorte = res?.data ?? []
})
},
loadAdvancedFilterOptions() {
this.$api.call(ApiOrt.getAdvancedFilterOptions())
.then(res => {
this.advancedFilterOptions = res?.data ?? {}
})
},
loadRooms() {
// only the latest request may fill the table, older answers can arrive later
const request = ++this.roomsRequest
this.loading = true
// empty and unchecked filters stay out of the request
const advancedFilters = Object.fromEntries(Object.entries(this.advancedFilters).filter(([, value]) => value !== '' && value !== false))
this.$api.call(ApiOrt.getRooms(this.datum.toISOString(), this.getTimeString(this.von), this.getTimeString(this.bis), this.selectedType?.raumtyp_kurzbz ?? '', this.anzahl, this.standort_id, advancedFilters))
.then(res => {
if(request === this.roomsRequest && res?.data?.retval) this.setupData(res.data.retval)
})
.finally(() => {
if(request === this.roomsRequest) this.loading = false
})
},
handleUuidDefined(uuid) {
this.tabulatorUuid = uuid
@@ -160,6 +233,9 @@ export const Raumsuche = {
this.normalizeAnzahl()
this.loadRooms()
},
resetAdvancedFilters() {
this.advancedFilters = { ...ADVANCED_DEFAULTS }
},
setRoute(val) {
// TODO: router push
},
@@ -180,6 +256,8 @@ export const Raumsuche = {
await this.tableBuiltPromise
this.loadRoomTypes()
this.loadStandorte()
this.loadAdvancedFilterOptions()
this.loadRooms()
const tableID = this.tabulatorUuid ? ('-' + this.tabulatorUuid) : ''
@@ -195,14 +273,17 @@ export const Raumsuche = {
}
},
created() {
this.phrasenPromise = this.$p.loadCategory(['rauminfo', 'global'])
this.phrasenPromise = this.$p.loadCategory(['rauminfo', 'global', 'ui', 'filter'])
this.phrasenPromise.then(()=> {this.phrasenResolved = true})
},
mounted() {
this.setupMounted()
},
template: `
<h1 class="h3">{{$p.t('rauminfo/roomSearch')}}</h1>
<div class="d-flex align-items-center gap-2 mb-2">
<h1 class="h3 mb-0">{{$p.t('rauminfo/roomSearch')}}</h1>
<in-view-help button-class="fs-5 text-body-secondary" :text="$p.t('rauminfo/tooltipRaumsuchev2')"></in-view-help>
</div>
<hr>
<div class="row">
<div :class="{'pb-1': isMobile}" class="col-12 col-lg-2">
@@ -248,16 +329,24 @@ export const Raumsuche = {
</VueDatePicker>
</div>
<div :class="{'pb-1': isMobile}" class="col-12 col-lg-3">
<div :class="{'pb-1': isMobile}" class="col-12 col-lg-2">
<select ref="raumtyp" id="raumtypSelect" v-model="selectedType" class="form-select"
:aria-label="$p.t('global/studiensemester_auswaehlen')" @change="setRoute($event.target.value)">
<option :key="defaultType" selected :value="defaultType">{{defaultType.beschreibung}}</option>
:aria-label="$p.t('rauminfo/raumtyp')" @change="setRoute($event.target.value)">
<option :value="null">{{ $p.t('global/alle') }}</option>
<option v-for="typ in roomtypes" :key="typ" :value="typ">{{typ.beschreibung}}</option>
</select>
</div>
<div :class="{'pb-1': isMobile}" class="col-12 col-lg-2">
<select id="standortSelect" v-model="standort_id" class="form-select" :aria-label="$p.t('rauminfo/standort')">
<option :value="null">{{ $p.t('rauminfo/alleStandorte') }}</option>
<option value="none">{{ $p.t('rauminfo/ohneStandort') }}</option>
<option v-for="standort in standorte" :key="standort.standort_id" :value="standort.standort_id">{{ standort.bezeichnung }}</option>
</select>
</div>
<div :class="{'pb-2': isMobile}" class="col-12 col-lg-3">
<div :class="{'pb-2': isMobile}" class="col-12 col-lg">
<div class="input-group">
<span class="input-group-text">{{ $p.t('rauminfo/minCapacity') }}:</span>
<FormInput
@@ -275,22 +364,68 @@ export const Raumsuche = {
/>
</div>
</div>
<div class="col-12 col-lg-2">
<button class="btn btn-primary border-0" @click="search">{{ $p.t('rauminfo/roomSearch') }} <i class="fa fa-magnifying-glass"></i></button>
</div>
</div>
<core-filter-cmpt
v-if="phrasenResolved"
@uuidDefined="handleUuidDefined"
:title="''"
ref="raumsucheTable"
:tabulator-options="raumsucheTableOptions"
:tabulator-events="raumsucheTableEventHandlers"
tableOnly
:sideMenu="false"
/>
<div class="d-flex justify-content-end my-1">
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none text-body-secondary"
:aria-expanded="showAdvancedFilters" aria-controls="raumsucheAdvancedFilters"
@click="showAdvancedFilters = !showAdvancedFilters">
<i class="fa-solid fa-sliders me-1"></i>{{ $p.t('rauminfo/erweiterteFilter') }}
<span v-if="activeAdvancedFilters" class="badge rounded-pill bg-primary ms-1">{{ activeAdvancedFilters }}</span>
<i class="fa-solid ms-1" :class="showAdvancedFilters ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
</button>
</div>
<div v-show="showAdvancedFilters" id="raumsucheAdvancedFilters" class="row g-2 mb-3">
<div v-for="filter in advancedFilterFields" :key="filter.column" class="col-12 col-md-6 col-lg-3"
:class="{'align-self-end': filter.type === 'flag' || filter.type === 'exists'}">
<div v-if="filter.type === 'flag' || filter.type === 'exists'" class="form-check">
<input :id="'raumsucheFilter-' + filter.column" v-model="advancedFilters[filter.column]" type="checkbox" class="form-check-input">
<label :for="'raumsucheFilter-' + filter.column" class="form-check-label">{{ $p.t(filter.label) }}</label>
</div>
<template v-else>
<label :for="'raumsucheFilter-' + filter.column" class="form-label small mb-1">{{ $p.t(filter.label) }}</label>
<input v-if="filter.type === 'text'" :id="'raumsucheFilter-' + filter.column"
v-model.lazy.trim="advancedFilters[filter.column]" type="text" class="form-control form-control-sm">
<select v-else-if="filter.type === 'select'" :id="'raumsucheFilter-' + filter.column"
v-model="advancedFilters[filter.column]" class="form-select form-select-sm">
<option value="">{{ $p.t('ui/alle') }}</option>
<option v-for="option in advancedFilterOptions[filter.column]" :key="option" :value="option">{{ option }}</option>
</select>
<div v-else class="input-group input-group-sm">
<input :id="'raumsucheFilter-' + filter.column" v-model.lazy="advancedFilters[filter.column + '_min']"
type="number" step="any" class="form-control"
:placeholder="$p.t('ui/von')" :aria-label="$p.t(filter.label) + ' ' + $p.t('ui/von')">
<input v-model.lazy="advancedFilters[filter.column + '_max']"
type="number" step="any" class="form-control"
:placeholder="$p.t('global/bis')" :aria-label="$p.t(filter.label) + ' ' + $p.t('global/bis')">
</div>
</template>
</div>
<div class="col-12 text-end">
<button type="button" class="btn btn-link btn-sm p-0" :disabled="!activeAdvancedFilters" @click="resetAdvancedFilters">
{{ $p.t('filter/filterDelete') }}
</button>
</div>
</div>
<div class="position-relative">
<div :class="{'opacity-50': loading}">
<core-filter-cmpt
v-if="phrasenResolved"
@uuidDefined="handleUuidDefined"
:title="''"
ref="raumsucheTable"
:tabulator-options="raumsucheTableOptions"
:tabulator-events="raumsucheTableEventHandlers"
tableOnly
:sideMenu="false"
/>
</div>
<div v-if="loading" class="position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center">
<i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
</div>
`,
};
@@ -91,6 +91,15 @@ export default {
.then(() => this.deleteZeitsperre(zeitsperre_id))
.catch(this.$fhcAlert.handleSystemError);
},
alertMailstatus(response) {
if(response?.meta?.mailstatus) {
if(response.meta.mailstatus.mailsent) {
this.$fhcAlert.alertInfo(response.meta.mailstatus.msg);
} else {
this.$fhcAlert.alertWarning(response.meta.mailstatus.msg);
}
}
},
saveZeitsperre(zeitsperreId = null) {
const isNew = !zeitsperreId;
@@ -109,8 +118,9 @@ export default {
return this.$refs.dataZeitsperre
.call(request)
.then(() => {
.then((response) => {
this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successSave'));
this.alertMailstatus(response);
this.reset();
this.reload();
@@ -122,6 +132,7 @@ export default {
.call(ApiTimelocks.deleteZeitsperre(zeitsperre_id))
.then(response => {
this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successDelete'));
this.alertMailstatus(response);
})
.catch(this.$fhcAlert.handleSystemError)
.finally(()=> {
+34
View File
@@ -0,0 +1,34 @@
import InViewTooltip from '../directives/inViewTooltip.js';
/**
* ? icon with a help text in the in-view tooltip. The panel stays inside the viewport, also at the
* screen edge, in a modal and on a phone: a plain tooltip ran out of the screen there.
*
* Help texts are long, so the panel is sticky by default: it sticks after a hover of 1.4 s or a click,
* a tap toggles it on touch. sticky: false makes it a plain hover tooltip.
*
* The view needs public/css/components/inViewTooltip.css.
*
* Usage: <in-view-help :text="$p.t('category/phrase')" button-class="fs-5 text-body-secondary"></in-view-help>
*/
export default {
name: 'InViewHelp',
directives: {
tooltip: InViewTooltip
},
props: {
text: { type: String, default: '' },
sticky: { type: Boolean, default: true },
buttonClass: { type: [String, Array, Object], default: '' }
},
template: /*html*/ `
<button
type="button"
class="btn btn-link p-0 fhc-in-view-help-btn"
:class="buttonClass"
:aria-label="$p.t('ui/hilfe')"
v-tooltip="{ value: text, sticky }"
>
<i class="fa fa-circle-question" aria-hidden="true"></i>
</button>`
};
@@ -56,7 +56,8 @@ export default {
anmerkung: null,
protokoll: null,
note: null,
link: null
link: null,
uhrzeit: null
},
statusNew: true,
arrTypen: [],
@@ -310,6 +311,7 @@ export default {
this.formData.pruefungstyp_kurzbz = data.pruefungstyp_kurzbz;
this.formData.datum = data.datum;
this.formData.uhrzeit = data.uhrzeit;
this.formData.sponsion = data.sponsion;
this.formData.akadgrad_id = data.akadgrad_id;
this.formData.pruefungsantritt_kurzbz = data.pruefungsantritt_kurzbz;
@@ -411,6 +413,7 @@ export default {
pruefer3_label: this.selectedPruefer3?.label || null,
akadgrad_id: this.formData.akadgrad_id,
datum: this.formData.datum,
uhrzeit: this.formData.uhrzeit || null,
sponsion: this.formData.sponsion
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
@@ -527,6 +530,7 @@ export default {
this.formData.abschlussbeurteilung_kurzbz = null;
this.formData.datum = null;
this.formData.sponsion = null;
this.formData.uhrzeit = null;
this.formData.pruefer1 = null;
this.formData.pruefer2 = null;
this.formData.pruefer3 = null;
@@ -620,7 +624,7 @@ export default {
);
}
});
},
}
},
created() {
this.$api
@@ -715,7 +719,6 @@ export default {
</template>
<form-form ref="formFinalExam" @submit.prevent>
<legend>{{this.$p.t('global','details')}}</legend>
<p v-if="statusNew">[{{$p.t('ui', 'neu')}}]</p>
@@ -916,6 +919,22 @@ export default {
</form-input>
</div>
<div v-if="!config.hiddenFields?.includes('uhrzeit')" class="row mb-3">
<form-input
container-class="col-6 stv-details-uhrzeit"
:label="$p.t('global', 'uhrzeit')"
type="DatePicker"
v-model="formData.uhrzeit"
model-type="HH:mm:ss"
time-picker
text-input
name="uhrzeit"
:teleport="true"
:cancel-text="$p.t('ui', 'abbrechen')"
:select-text="$p.t('ui', 'ok')"
/>
</div>
<div class="row mb-3">
<form-input
container-class="col-6 stv-details-abschlusspruefung-sponsion"
@@ -979,7 +998,7 @@ export default {
:cisRoot="cisRoot"
@linkGenerated="printDocument"
></abschlusspruefung-dropdown>
</Teleport>
</Teleport>
</div>
`
}
@@ -317,7 +317,7 @@ export default {
<div class="stv-details-kontakt-contact h-100 pt-3">
<!--Modal: contactModal-->
<bs-modal ref="contactModal">
<bs-modal ref="contactModal" @hideBsModal.stop="">
<template #title>
<p v-if="statusNew" class="fw-bold mt-3">{{$p.t('person', 'kontakt_new')}}</p>
<p v-else class="fw-bold mt-3">{{$p.t('person', 'kontakt_edit')}}</p>
@@ -604,9 +604,11 @@ export default {
<!--Modal: KontaktdatenModal -->
<bs-modal
v-if="autocompleteSelectedBetreuer && autocompleteSelectedBetreuer.person_id"
@hideBsModal.stop=""
ref="kontaktdatenModal"
dialog-class="modal-xl modal-dialog-scrollable"
v-if="autocompleteSelectedBetreuer && autocompleteSelectedBetreuer.person_id">
>
<template #title>
<p class="fw-bold mt-3">{{$p.t('projektarbeit', 'kontaktdatenBearbeiten')}}</p>
+352
View File
@@ -0,0 +1,352 @@
/**
* v-tooltip with a panel that stays in view, also at the screen edge, in a modal and on a phone.
*
* - The panel is fixed and stays inside the viewport. It opens below the anchor, above it when
* it fits only there or that side has more space. It grows away from the nearer viewport side.
* - On an anchor wider than the panel, e.g. a long table cell, it opens at the mouse, not at the anchor edge.
* - One panel at a time, appended to the body. A modal or a scrolling container cannot clip it.
* - A mouse hover or a keyboard focus opens it. Escape, a click outside or a scroll closes it.
*
* Sticky, for several sentences or a list: a short label is read before the hover pause ends, a long text is not.
* - After HOLD_DELAY_MS + HOLD_FILL_MS of hovering the panel sticks, the border fills up in the end.
* A click on the anchor makes it stick at once. Touch has no hover: a tap toggles the sticky panel.
* - A sticky panel stays until a click outside, Escape, a scroll or an opening bootstrap modal.
*
* The view needs public/css/components/inViewTooltip.css.
*
* Usage examples:
*
* 1) Directive, registered for the app or the component:
* app.directive('tooltip', InViewTooltip) or directives: { tooltip: InViewTooltip }
* <button v-tooltip="label"> a short label, hover only
* <span v-tooltip.sticky="longText"> several sentences or a list
* v-tooltip="{ value: text, disabled: bool, sticky: bool }", the modifier .top prefers the place above
*
* 2) Without a directive, e.g. a tabulator cell. The content is a text, a DOM node or a function:
* { event: 'cellMouseEnter', handler: (e, cell) => hoverTooltip(cell.getElement(), () => buildNode(cell), { delay: 300, sticky: true, closeOnClick: true }) },
* { event: 'cellMouseLeave', handler: (e, cell) => hideTooltip(cell.getElement()) }
*
* 3) A ? icon with a help text: components/InViewHelp.js
*/
// hover time until a sticky panel sticks: a pause without motion filters out a pointer that only passes by,
// then a short fill shows the pin. A long fill would draw the eye away from the text
const HOLD_DELAY_MS = 1000
const HOLD_FILL_MS = 400
const HIDE_DELAY_MS = 150 // the pointer can cross the gap from the anchor into the panel
const WATCH_MS = 250 // an anchor can leave the page without an unmount, e.g. in a hidden keep-alive tab
const PANEL_MAX_WIDTH = 512 // px
const PANEL_MARGIN = 16 // px between the panel and the viewport edge
const PANEL_GAP = 6 // px between the anchor and the panel
const PANEL_RADIUS = 5 // px, the inner radius of the panel border. The progress border follows it
const PANEL_POINTER_INSET = 16 // px, the horizontal padding of the panel: the text starts below the mouse
const clamp = (value, min, max) => Math.min(Math.max(value, min), max)
let active = null // the open panel, only one at a time
let pending = null // a delayed show
let panelCount = 0
// the last pointer. Its type tells a tap from a mouse also for the mouse events of tabulator, a tap fires
// emulated mouse events too: pointerover fires before them. Its place puts the panel next to the mouse
const pointer = { type: 'mouse', x: null, y: null }
const trackPointer = event => {
pointer.type = event.pointerType
pointer.x = event.clientX
pointer.y = event.clientY
}
for (const name of ['pointerover', 'pointermove', 'pointerdown'])
document.addEventListener(name, trackPointer, { capture: true, passive: true })
/**
* Opens the panel for an anchor element.
*
* content: a text, a DOM node or a function that returns one of them.
* options: prefer ('bottom' | 'top'), delay (ms), sticky (the hover timer pins the panel), pinned (sticks at once),
* closeOnClick (a click on the anchor closes the panel, e.g. a table cell with a click action)
*/
export function showTooltip(anchor, content, options = {}) {
cancelPending()
if (!options.delay) return open(anchor, content, options)
pending = { anchor, timer: setTimeout(() => { pending = null; open(anchor, content, options) }, options.delay) }
}
// for mouse events without a pointer type, e.g. the cell events of tabulator
export function hoverTooltip(anchor, content, options) {
if (pointer.type === 'mouse') showTooltip(anchor, content, options)
}
// closes the panel of the anchor after HIDE_DELAY_MS, a sticky panel stays
export function hideTooltip(anchor) {
if (pending?.anchor === anchor) cancelPending()
if (active?.anchor !== anchor || active.pinned) return
clearTimeout(active.hideTimer)
active.hideTimer = setTimeout(close, HIDE_DELAY_MS)
}
// closes the open panel, with an anchor only the panel of that anchor
export function closeTooltip(anchor) {
if (!anchor || pending?.anchor === anchor) cancelPending()
if (!anchor || active?.anchor === anchor) close()
}
function isTooltipPinned(anchor) {
return active?.anchor === anchor && active.pinned
}
// new content for an open panel, an empty text closes it
function updateTooltip(anchor, content) {
if (active?.anchor !== anchor) return
if (!content) return close()
render(active.panel, content)
place()
}
function open(anchor, content, { prefer = 'bottom', sticky = false, pinned = false, closeOnClick = false }) {
if (active?.anchor === anchor) {
clearTimeout(active.hideTimer)
if (pinned) pin()
return
}
close()
sticky = sticky || pinned
const panel = document.createElement('div')
panel.id = 'fhc-in-view-tooltip-' + ++panelCount
panel.className = 'fhc-in-view-tooltip'
panel.setAttribute('role', 'tooltip')
panel.innerHTML = '<div class="fhc-in-view-tooltip-body"></div>'
if (sticky) {
panel.style.setProperty('--fhc-in-view-tooltip-hold-delay', HOLD_DELAY_MS + 'ms')
panel.style.setProperty('--fhc-in-view-tooltip-hold-fill', HOLD_FILL_MS + 'ms')
panel.insertAdjacentHTML('beforeend', '<svg class="fhc-in-view-tooltip-progress" aria-hidden="true">'
+ '<rect width="100%" height="100%" rx="' + PANEL_RADIUS + '"></rect></svg>')
}
render(panel, content)
document.body.appendChild(panel)
// the pointer at the opening, while it is over the anchor. A keyboard focus has none
const rect = anchor.getBoundingClientRect()
const overAnchor = pointer.x !== null && pointer.x >= rect.left && pointer.x <= rect.right
&& pointer.y >= rect.top && pointer.y <= rect.bottom
active = {
anchor,
panel,
prefer,
pointerX: overAnchor ? pointer.x : null,
sticky,
closeOnClick,
pinned: false,
describedBy: anchor.getAttribute('aria-describedby'),
holdTimer: null,
hideTimer: null,
watchTimer: setInterval(() => {
if (!anchor.isConnected || !anchor.getClientRects().length) close()
}, WATCH_MS)
}
place()
anchor.setAttribute('aria-describedby', panel.id)
if (pinned) {
pin()
} else if (sticky) {
panel.classList.add('fhc-in-view-tooltip--holding')
active.holdTimer = setTimeout(pin, HOLD_DELAY_MS + HOLD_FILL_MS)
}
panel.addEventListener('pointerenter', onPanelEnter)
panel.addEventListener('pointerleave', onPanelLeave)
panel.addEventListener('click', onPanelClick)
document.addEventListener('pointerdown', onPointerDown, true)
document.addEventListener('keydown', onKeyDown, true)
// an anchor often opens a modal on click, the sticky panel must not cover it
document.addEventListener('show.bs.modal', close)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', place)
}
function pin() {
if (!active || active.pinned || !active.sticky) return
clearTimeout(active.holdTimer)
clearTimeout(active.hideTimer)
active.pinned = true
active.panel.classList.remove('fhc-in-view-tooltip--holding')
active.panel.classList.add('fhc-in-view-tooltip--pinned')
}
function close() {
if (!active) return
const { anchor, panel, describedBy } = active
clearTimeout(active.holdTimer)
clearTimeout(active.hideTimer)
clearInterval(active.watchTimer)
active = null
panel.remove()
if (describedBy) anchor.setAttribute('aria-describedby', describedBy)
else anchor.removeAttribute('aria-describedby')
document.removeEventListener('pointerdown', onPointerDown, true)
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('show.bs.modal', close)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', place)
}
function cancelPending() {
if (!pending) return
clearTimeout(pending.timer)
pending = null
}
function render(panel, content) {
if (typeof content === 'function') content = content()
const body = panel.querySelector('.fhc-in-view-tooltip-body')
if (content instanceof Node) body.replaceChildren(content)
else body.textContent = content
}
function place() {
if (!active) return
const { anchor, panel, prefer } = active
const rect = anchor.getBoundingClientRect()
const viewWidth = document.documentElement.clientWidth
const viewHeight = document.documentElement.clientHeight
// measure the natural size first
panel.style.maxWidth = Math.min(PANEL_MAX_WIDTH, viewWidth - 2 * PANEL_MARGIN) + 'px'
panel.style.maxHeight = ''
panel.style.left = '0px'
panel.style.top = '0px'
panel.style.bottom = ''
const width = panel.offsetWidth
const height = panel.offsetHeight
const space = {
bottom: viewHeight - rect.bottom - PANEL_GAP - PANEL_MARGIN,
top: rect.top - PANEL_GAP - PANEL_MARGIN
}
const other = prefer === 'top' ? 'bottom' : 'top'
let side = prefer
if (space[prefer] < height) side = space[other] >= height || space[other] > space[prefer] ? other : prefer
// a panel at least as wide as the anchor starts at the anchor edge, grows away from the nearer viewport side
// and spans the whole anchor. On a wider anchor that edge can be far from the mouse: the text starts below
// the pointer, the clamp moves the panel to the left only at the right viewport edge
const { pointerX } = active
let left
if (pointerX === null || width >= rect.width) left = rect.left + rect.width / 2 < viewWidth / 2 ? rect.left : rect.right - width
else left = pointerX - PANEL_POINTER_INSET
panel.style.left = clamp(left, PANEL_MARGIN, viewWidth - PANEL_MARGIN - width) + 'px'
panel.style.maxHeight = space[side] + 'px'
if (side === 'bottom') {
panel.style.top = rect.bottom + PANEL_GAP + 'px'
} else {
panel.style.top = ''
panel.style.bottom = viewHeight - rect.top + PANEL_GAP + 'px'
}
if (!active.sticky) return
// the dash of the progress border is as long as the rounded rect along the inner edge of the 1 px border.
// Rounded up, else a gap stays at its start: clientWidth drops the fraction of the width
const box = panel.getBoundingClientRect()
const perimeter = 2 * (box.width + box.height - 4) - (8 - 2 * Math.PI) * PANEL_RADIUS
panel.style.setProperty('--fhc-in-view-tooltip-perimeter', Math.ceil(perimeter) + 1)
}
function onPanelEnter() {
cancelPending()
clearTimeout(active.hideTimer)
}
function onPanelLeave(event) {
if (event.pointerType === 'mouse') hideTooltip(active.anchor)
}
// a click into the panel closes it, a text selection keeps it
function onPanelClick() {
if (!String(window.getSelection())) close()
}
function onPointerDown(event) {
const { anchor, panel, closeOnClick } = active
if (panel.contains(event.target)) return
if (anchor.contains(event.target) && !closeOnClick) return
cancelPending()
close()
}
// Escape closes only the panel, not a modal around the anchor
function onKeyDown(event) {
if (event.key !== 'Escape') return
event.stopPropagation()
close()
}
// the panel is fixed, a scroll moves the anchor away from it. A scroll inside the panel keeps it
function onScroll(event) {
if (!active.panel.contains(event.target)) close()
}
// reads the binding: "text" or { value, disabled, sticky }, the modifiers .sticky and .top
function read(state, binding) {
const value = binding.value
const isObject = value && typeof value === 'object'
state.text = (isObject ? (value.disabled ? '' : value.value) : value) ?? ''
state.sticky = !!(binding.modifiers.sticky || isObject && value.sticky)
state.prefer = binding.modifiers.top ? 'top' : 'bottom'
}
export default {
mounted(el, binding) {
const state = el._fhcInViewTooltip = { pointerType: 'mouse' }
read(state, binding)
const show = options => {
if (state.text) showTooltip(el, state.text, { prefer: state.prefer, sticky: state.sticky, ...options })
}
state.listeners = {
pointerenter: event => { if (event.pointerType === 'mouse') show() },
pointerleave: event => { if (event.pointerType === 'mouse') hideTooltip(el) },
pointerdown: event => { state.pointerType = event.pointerType },
// a mouse click or a key pins a sticky panel at once, a tap toggles it
click: () => {
if (!state.sticky) return
if (state.pointerType !== 'mouse' && isTooltipPinned(el)) closeTooltip(el)
else show({ pinned: true })
state.pointerType = 'mouse' // a key click has no pointerdown
},
focusin: event => { if (event.target.matches(':focus-visible')) show() },
focusout: () => hideTooltip(el)
}
// the click captures: it pins the panel before the click action of the anchor runs, so a modal
// that this action opens closes the panel again. The pointer events must not capture the children
for (const [name, listener] of Object.entries(state.listeners)) el.addEventListener(name, listener, name === 'click')
},
updated(el, binding) {
const state = el._fhcInViewTooltip
const text = state.text
read(state, binding)
if (state.text !== text) updateTooltip(el, state.text)
},
beforeUnmount(el) {
const state = el._fhcInViewTooltip
closeTooltip(el)
for (const [name, listener] of Object.entries(state.listeners)) el.removeEventListener(name, listener, name === 'click')
delete el._fhcInViewTooltip
}
}
-1
View File
@@ -1 +0,0 @@
../vendor
+1
View File
@@ -105,6 +105,7 @@ require_once('dbupdate_3.4/78292_gehaltsanpassungtyp.php');
require_once('dbupdate_3.4/54225_reihungstest_ablauf_fuer_quereinsteiger.php');
require_once('dbupdate_3.4/77757_widgets_berechtigungen.php');
require_once('dbupdate_3.4/77048_cis4_mitarbeiter_studstatus_widget.php');
require_once('dbupdate_3.4/83538_cis4_employees.php');
// *** Pruefung und hinzufuegen der neuen Attribute und Tabellen
echo '<H2>Pruefe Tabellen und Attribute!</H2>';
@@ -1,4 +1,22 @@
<?php
// Add permission: dashboard/widget_studstatus_ma
if($result = @$db->db_query("SELECT 1 FROM system.tbl_berechtigung WHERE berechtigung_kurzbz = 'dashboard/widget_studstatus_ma';"))
{
if($db->db_num_rows($result) == 0)
{
$qry = "INSERT INTO system.tbl_berechtigung(berechtigung_kurzbz, beschreibung) VALUES('dashboard/widget_studstatus_ma', 'Dashboard Widget Studstatus f. Mitarbeiter');";
if(!$db->db_query($qry))
{
echo '<strong>system.tbl_berechtigung '.$db->db_last_error().'</strong><br>';
}
else
{
echo 'system.tbl_berechtigung: Added permission "dashboard/widget_studstatus_ma"<br>';
}
}
}
// neues widget mit widget_kurzbz = 'studstatus' als Zeile hinzufügen
if($result = @$db->db_query("SELECT 1 FROM dashboard.tbl_widget WHERE widget_kurzbz= 'studstatus';"))
{
@@ -6,14 +24,13 @@ if($result = @$db->db_query("SELECT 1 FROM dashboard.tbl_widget WHERE widget_kur
{
$qry = "
INSERT INTO dashboard.tbl_widget (
widget_id,
widget_kurzbz,
beschreibung,
arguments,
setup
setup,
berechtigung_kurzbz
)
VALUES (
9,
'studstatus',
'Widget Todos Studierendenstatus',
'{
@@ -33,7 +50,8 @@ if($result = @$db->db_query("SELECT 1 FROM dashboard.tbl_widget WHERE widget_kur
\"min\": 1
},
\"hideFooter\": true
}'::jsonb
}'::jsonb,
'dashboard/widget_studstatus_ma'
);";
if(!$db->db_query($qry))
@@ -44,7 +62,7 @@ if($result = @$db->db_query("SELECT 1 FROM dashboard.tbl_widget WHERE widget_kur
}
// neuer eintrag in dashboard.tbl_dashboard_widget
if($result = @$db->db_query("SELECT 1 FROM dashboard.tbl_dashboard_widget WHERE dashboard_id= 1 AND widget_id = 9;"))
if($result = @$db->db_query("SELECT 1 FROM dashboard.tbl_dashboard_widget join dashboard.tbl_dashboard USING(dashboard_id) join dashboard.tbl_widget USING(widget_id) WHERE dashboard_kurzbz= 'CIS' AND widget_kurzbz = 'studstatus';"))
{
if($db->db_num_rows($result) == 0)
{
@@ -52,12 +70,33 @@ if($result = @$db->db_query("SELECT 1 FROM dashboard.tbl_dashboard_widget WHERE
INSERT INTO
dashboard.tbl_dashboard_widget (dashboard_id, widget_id)
VALUES
(1, 9);
(
(SELECT dashboard_id FROM dashboard.tbl_dashboard WHERE dashboard_kurzbz = 'CIS'),
(SELECT widget_id FROM dashboard.tbl_widget WHERE widget_kurzbz = 'studstatus')
);
";
if(!$db->db_query($qry))
echo '<strong>dashboard.tbl_dashboard_widget: '.$db->db_last_error().'</strong><br>';
else
echo '<br>dashboard.tbl_dashboard_widget: Widget studstatus hinzugefuegt!<br>';
echo '<br>dashboard.tbl_dashboard_widget: Widget "studstatus" zum Dashboard "CIS" hinzugefuegt!<br>';
}
}
// Add permission: dashboard/widget_studstatus_ma if widget was created without
if($result = @$db->db_query("SELECT 1 FROM dashboard.tbl_widget WHERE berechtigung_kurzbz = 'dashboard/widget_studstatus_ma' and widget_kurzbz = 'studstatus';"))
{
if($db->db_num_rows($result) == 0)
{
$qry = "UPDATE dashboard.tbl_widget SET berechtigung_kurzbz = 'dashboard/widget_studstatus_ma' WHERE widget_kurzbz = 'studstatus';";
if(!$db->db_query($qry))
{
echo '<strong>dashboard.tbl_widget '.$db->db_last_error().'</strong><br>';
}
else
{
echo 'dashboard.tbl_widget: Added permission "dashboard/widget_studstatus_ma" to widget "studstatus"<br>';
}
}
}
@@ -0,0 +1,35 @@
<?php
// Add permission: cis/eigene_zeitsperren
if($result = @$db->db_query("SELECT 1 FROM system.tbl_berechtigung WHERE berechtigung_kurzbz = 'cis/eigene_zeitsperren';"))
{
if($db->db_num_rows($result) == 0)
{
$qry = "INSERT INTO system.tbl_berechtigung(berechtigung_kurzbz, beschreibung) VALUES('cis/eigene_zeitsperren', 'CI/VueJs Applikation Eigene Zeitsperren');";
if(!$db->db_query($qry))
{
echo '<strong>system.tbl_berechtigung '.$db->db_last_error().'</strong><br>';
}
else
{
echo 'system.tbl_berechtigung: Added permission "cis/eigene_zeitsperren"<br>';
}
}
}
// Add permission: cis/andere_zeitsperren
if($result = @$db->db_query("SELECT 1 FROM system.tbl_berechtigung WHERE berechtigung_kurzbz = 'cis/andere_zeitsperren';"))
{
if($db->db_num_rows($result) == 0)
{
$qry = "INSERT INTO system.tbl_berechtigung(berechtigung_kurzbz, beschreibung) VALUES('cis/andere_zeitsperren', 'CI/VueJs Applikation Zeitsperren anderer MA anzeigen');";
if(!$db->db_query($qry))
{
echo '<strong>system.tbl_berechtigung '.$db->db_last_error().'</strong><br>';
}
else
{
echo 'system.tbl_berechtigung: Added permission "cis/andere_zeitsperren"<br>';
}
}
}
+380
View File
@@ -21669,6 +21669,264 @@ array(
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'standort',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Standort",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "Site",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'alleStandorte',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Alle Standorte",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "All Sites",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'ohneStandort',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Ohne Standort",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "No Site",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'tooltipRaumsuchev2',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Die Raumsuche zeigt freie Räume für einen Zeitraum. Ein Raum ist frei, wenn im Stundenplan keine Lehrveranstaltung steht und niemand den Raum reserviert hat. Beim Öffnen zeigt die Liste die Räume, die ab jetzt für eine Stunde frei sind.
So finden Sie einen freien Raum:
1. Wählen Sie Datum und Uhrzeit.
2. Optional: Wählen Sie einen Raumtyp, einen Standort und eine Mindestpersonenkapazität.
Die Liste lädt neu, sobald Sie einen Filter ändern.
In der Liste:
• Raum Informationen: Das Symbol öffnet die Infoseite des Raums. Ein Strich bedeutet: Der Raum hat keine Infoseite.
• Raum Reservierungen: Das Symbol öffnet den Kalender des Raums. Der Kalender zeigt alle Lehrveranstaltungen und Reservierungen.",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "The room search shows free rooms for a time period. A room is free when the timetable shows no course in the room and nobody reserved the room. When you open the page, the list shows the rooms that are free from now for one hour.
How to find a free room:
1. Select the date and the time.
2. Optional: select a room type, a site and a minimum person capacity.
The list loads again when you change a filter.
In the list:
• Room Information: the icon opens the information page of the room. A dash means that the room has no information page.
• Room Reservations: the icon opens the calendar of the room. The calendar shows all courses and reservations.",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'erweiterteFilter',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Erweiterte Filter",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "Advanced filters",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'nurLehrraeume',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Nur Lehrräume",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "Only teaching rooms",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'nurMitInfoseite',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Nur Räume mit Infoseite",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "Only rooms with an information page",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'lehrraum',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Lehrraum",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "Teaching room",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'stockwerk',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Stockwerk",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "Floor",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'gebaeudeteil',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Gebäudeteil",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "Building section",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'flaeche',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Fläche in m²",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "Area in m²",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'rauminfo',
'phrase' => 'raumtyp',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => "Raumtyp",
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => "Room Type",
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'lehre',
@@ -67819,6 +68077,128 @@ I have been informed that I am under no obligation to consent to the transmissio
)
),
// MYLV PHRASES END
// Zeitsperren Sancho Subjects begin
array(
'app' => 'core',
'category' => 'zeitsperren',
'phrase' => 'zeitausgleichNeu',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Zeitausgleich wurde neu eingetragen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Compensatory time was entered',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'zeitsperren',
'phrase' => 'zeitausgleichGeaendert',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Zeitausgleich wurde geändert',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Compensatory time was changed',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'zeitsperren',
'phrase' => 'zeitausgleichGeloescht',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Zeitausgleich wurde gelöscht',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Compensatory time was deleted',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'zeitsperren',
'phrase' => 'VorgesetzteInformiert',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Email wurde an {vorgesetzte} versandt',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Email was sent to {vorgesetzte}',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'zeitsperren',
'phrase' => 'fehlerBeimSendenAufgetreten',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Fehler beim Senden des Freigabemails an {vorgesetzte}',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Error occured sending mail to {vorgesetzte}',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'zeitsperren',
'phrase' => 'konnteKeinFreigabemailVersendetWerden',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Es konnte kein Freigabemail versendet werden, da kein Vorgesetzter eingetragen ist!',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'An approval mail could not be sent because the “Superior” field was left blank!',
'description' => '',
'insertvon' => 'system'
)
)
),
// Zeitsperren Sancho Subjects end
);