Compare commits

..
Author SHA1 Message Date
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
Harald Bamberger f3d031ab16 cismenu: add cis-main-menu class to main nav, add li elements to ul, style cis-main-menu ul and li to prevent interferec with cms content styles, add draggable false to cis menu entries to improve menu scrolling especially on mobile devices 2026-10-01 11:48:51 +02:00
Harald Bamberger 3d8c4fa4d0 Merge branch 'feature-77757/WidgetsBerechtigung' 2026-09-30 15:48:00 +02:00
Harald Bamberger 272014bd78 remove odd phrases 2026-09-30 15:38:43 +02:00
Harald Bamberger 76a8e00edf Merge branch 'master' into feature-77757/WidgetsBerechtigung 2026-09-30 15:23:08 +02:00
Harald Bamberger ce62713790 Merge branch 'bug-82297/ausweis-date-preview-issue' 2026-09-30 12:11:13 +02:00
Harald Bamberger 34761db664 rename phrase 2026-09-30 10:56:30 +02:00
Harald Bamberger c7131ed775 revert changes to old cis since issue does not occur there 2026-09-30 10:53:01 +02:00
Harald Bamberger b57e1984a8 Merge branch 'master' into bug-82297/ausweis-date-preview-issue 2026-09-30 10:20:28 +02:00
Harald Bamberger acedc11be8 adapt styling detailheader employees 2026-09-24 14:39:38 +02:00
Harald Bamberger 15c1da74a2 CisMenu: do not overwrite calculated url if menuitem is multiple times in menu tree 2026-09-23 16:09:42 +02:00
Harald Bamberger d33b45d7cb Merge branch 'bhdev_backport_compat' 2026-09-23 14:58:26 +02:00
Andreas Österreicher b3b6d847a7 Merge branch 'feature-62260/international_skills_groupheader_filter' 2026-09-23 14:36:13 +02:00
Andreas Österreicher 80365de28c Merge branch 'feature-62602/mehrfachnennungen_innerhalb_einer_kategorie' 2026-09-23 14:35:36 +02:00
Andreas Österreicher 2d74522a4f Merge branch 'feature-54225/reihungstest_ablauf_fuer_quereinsteiger' 2026-09-23 12:28:35 +02:00
ma0048 d00aef66e1 master gemerged 2026-09-23 11:32:01 +02:00
ma0048 241eec3ef2 Merge remote-tracking branch 'origin/master' into feature-54225/reihungstest_ablauf_fuer_quereinsteiger
# Conflicts:
#	cis/testtool/menu.php
#	system/dbupdate_3.4.php
2026-09-23 11:29:43 +02:00
Harald Bamberger 8b44e4475b Merge branch 'master' of github.com:FH-Complete/FHC-Core into bhdev_backport_compat 2026-09-22 16:51:34 +02:00
Harald Bamberger d251987850 backport compat controller and component 2026-09-22 16:50:56 +02:00
ma0048 224cef7fad bug beim Loeschen von Antworten eines Prueflings in reihungstet_administration.php fixed 2026-09-17 13:29:58 +02:00
Ivymaster 5fff45e302 Added check for date existence 2026-08-27 11:00:43 +02:00
Johann Hoffmann d40040efd8 added new fkey to tbl_widget for system.tbl_berechtigung; check for permission status during load & in widget picker; if widget was already picked/configured but not permitted anymore -> show a generalized "missing permission" screen via fallback component during widget init; 2026-07-21 18:14:26 +02:00
Andreas Österreicher 9629345473 Merge branch 'master' into feature-62260/international_skills_groupheader_filter 2026-07-08 15:28:23 +02:00
Andreas Österreicher 4d72b0047a Merge branch 'master' into feature-62602/mehrfachnennungen_innerhalb_einer_kategorie 2026-07-08 15:26:41 +02:00
ma0048 c664c14afc phrase hinzugefuegt 2025-12-18 11:10:08 +01:00
ma0048 9183c4f04c massnahme einmalig flag hinzugefuegt 2025-10-01 12:03:18 +02:00
ma0048 6495f741b9 added missing phrase 2025-07-09 14:02:41 +02:00
ma0048 d3210f0d3d - foreign keys hinzugefuegt
- beim zuruecksetzen der gebiete, wird auch tbl_pruefling_gebiet zurueckgesetzt
2025-02-18 13:51:20 +01:00
ma0048 a0dce925d4 - rt holt sich zusätzlich alle gebiete aus den semestern davor 2025-01-14 10:56:28 +01:00
29 changed files with 1681 additions and 307 deletions
+44
View File
@@ -0,0 +1,44 @@
<?php
if (!defined('BASEPATH'))
exit('No direct script access allowed');
class Compat extends Auth_Controller
{
/**
* Constructor
*/
public function __construct()
{
parent::__construct([
'ci' => ['basis/cis:r'],
'legacy' => ['basis/cis:r'],
]);
}
// -----------------------------------------------------------------------------------------------------------------
// Public methods
/**
* @access public
* @return void
*/
public function ci()
{
$this->load->view('CisRouterView/CisRouterView.php',['route' => 'Compat']);
}
/**
* @access public
* @return void
*/
public function legacy()
{
$this->load->view('CisRouterView/CisRouterView.php',['route' => 'Compat']);
}
}
@@ -67,8 +67,11 @@ class CisMenu extends FHCAPI_Controller
private function generateUrlForMenuItem($menuItem)
{
$menuItem->url = $this->menuItemUrlHelper($menuItem);
unset($menuItem->content);
if(!isset($menuItem->url))
{
$menuItem->url = $this->menuItemUrlHelper($menuItem);
unset($menuItem->content);
}
if ($menuItem->childs && count($menuItem->childs)) {
$menuItem->childs = $this->generateUrlsForMenuItems($menuItem->childs);
@@ -83,7 +86,7 @@ class CisMenu extends FHCAPI_Controller
return site_url("/CisVue/Cms/content/" . $menuItem->content_id);
}
if (!$menuItem->content || !mb_strlen($menuItem->content)) {
if (!isset($menuItem->content) || !$menuItem->content || !mb_strlen($menuItem->content)) {
return '';
}
+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
*/
@@ -36,7 +48,9 @@ class Ort extends FHCAPI_Controller
'ContentID' => self::PERM_LOGGED,
'getOrtKurzbzContent' => self::PERM_LOGGED,
'getRooms' => self::PERM_LOGGED,
'getTypes' => self::PERM_LOGGED
'getTypes' => self::PERM_LOGGED,
'getStandorte' => self::PERM_LOGGED,
'getAdvancedFilterOptions' => self::PERM_LOGGED
]);
$this->load->model('ressource/Ort_model', 'OrtModel');
@@ -56,6 +70,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());
}
@@ -65,6 +90,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');
@@ -75,21 +101,33 @@ class Ort extends FHCAPI_Controller
$bisStunde = getData($this->StundeModel->getStundeForTime($bis))[0]->stunde;
$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
@@ -127,6 +165,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
*/
@@ -174,5 +247,49 @@ class Ort extends FHCAPI_Controller
$this->terminateWithSuccess($content);
}
//------------------------------------------------------------------------------------------------------------------
// 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;
}
}
@@ -101,6 +101,9 @@ class Widget extends FHCAPI_Controller
$tmpsetup = json_decode($widget->setup);
$tmpsetup->file = absoluteJsImportUrl($tmpsetup->file);
$widget->setup = $tmpsetup;
$widget->permitted = empty($widget->berechtigung_kurzbz)
|| $this->permissionlib->isBerechtigt($widget->berechtigung_kurzbz);
return $widget;
}, $widgets);
@@ -21,6 +21,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'
+1
View File
@@ -632,6 +632,7 @@ elseif (isset($prestudent_id))
$ps_obj->lastStatus = $ps_tmp->status_kurzbz; // letzten Status dem result array hinzufügen
$ps_obj->status_mehrsprachig = $ps_tmp->status_mehrsprachig;
$ps_obj->ausbildungssemester = $ps_tmp->ausbildungssemester;
}
// Falls Status 'Abgewiesene' vorhanden, nach hinten reihen
+33 -15
View File
@@ -29,6 +29,7 @@ require_once('../../include/sprache.class.php');
require_once '../../include/phrasen.class.php';
require_once '../../include/studiengang.class.php';
require_once('../../include/gebiet.class.php');
require_once('../../include/gebiet_pruefling.class.php');
if (!$db = new basis_db())
die('Fehler beim Oeffnen der Datenbankverbindung');
@@ -198,8 +199,8 @@ else if (isset($_SESSION['pruefling_id']))
)
SELECT DISTINCT ON
(gebiet_id, semester, reihung)
SELECT * FROM (SELECT DISTINCT ON
(gebiet_id)
semester,
gebiet_id,
STRING_AGG(studiengang_kz::TEXT, ', ' ORDER BY studiengang_kz) AS studiengang_kz_list,
@@ -226,13 +227,7 @@ else if (isset($_SESSION['pruefling_id']))
JOIN
testtool.tbl_gebiet USING (gebiet_id)
WHERE
(
(prestudent_data.semester= 1 AND tbl_ablauf.semester = 1)
OR
(prestudent_data.semester= 2 AND tbl_ablauf.semester = 2)
OR
(prestudent_data.semester= 3 AND tbl_ablauf.semester IN (1,3))
)
tbl_ablauf.semester <= prestudent_data.semester
AND (
prestudent_data.studienplan_id = tbl_ablauf.studienplan_id
OR
@@ -259,9 +254,7 @@ else if (isset($_SESSION['pruefling_id']))
JOIN
testtool.tbl_gebiet USING (gebiet_id)
WHERE
(prestudent_data.semester= 1 AND tbl_ablauf.semester = 1)
OR
(prestudent_data.semester= 3 AND tbl_ablauf.semester IN (1,3))
tbl_ablauf.semester <= prestudent_data.semester
)
) temp
) temp2
@@ -270,7 +263,7 @@ else if (isset($_SESSION['pruefling_id']))
semester,
gebiet_id,
bezeichnung,
". $bezeichnung_mehrsprachig_sel ."
". $bezeichnung_mehrsprachig_sel ." ) temp3
ORDER BY
semester,
@@ -283,6 +276,8 @@ else if (isset($_SESSION['pruefling_id']))
$lastsemester = '';
$quereinsteiger_stg = '';
$_SESSION['alleGebiete']= [];
$gebiet_pruefling = new gebiet_pruefling();
$quereinsteiger_menu = false;
while($row = $db->db_fetch_object($result))
{
//Jedes Semester in einer eigenen Tabelle anzeigen
@@ -294,8 +289,25 @@ else if (isset($_SESSION['pruefling_id']))
}
$lastsemester = $row->semester;
echo '<table border="0" cellspacing="0" cellpadding="0" id="Gebiet" style="display: visible; border-collapse: separate; border-spacing: 0 3px; margin-top: 5px;">';
echo '<tr><td class="HeaderTesttool">'. ($row->semester == '1' ? $p->t('testtool/basisgebiete') : $p->t('testtool/quereinstiegsgebiete')).'</td></tr>';
if ($row->semester == '1')
{
// Basisgebiete anzeigen
echo '<table border="0" cellspacing="0" cellpadding="0" id="Gebiet" style="display: visible; border-collapse: separate; border-spacing: 0 3px; margin-top: 5px;">';
echo '<tr><td class="HeaderTesttool">' . $p->t('testtool/basisgebiete') . '</td></tr>';
}
else
{
if (!$quereinsteiger_menu)
{
echo '<table border="0" cellspacing="0" cellpadding="0" id="Gebiet" style="display: visible; border-collapse: separate; border-spacing: 0 3px; margin-top: 5px;">';
echo '<tr><td class="HeaderTesttool">' . $p->t('testtool/quereinstiegsgebiete') . '</td></tr>';
$quereinsteiger_menu = true;
}
else
{
echo '<table border="0" cellspacing="0" cellpadding="0" id="Gebiet" style="display: visible; border-collapse: separate; border-spacing: 0 3px; margin-top: 5px;">';
}
}
}
// Bei Quereinstiegsgebieten nach STG clustern und die STG anzeigen
@@ -394,6 +406,12 @@ else if (isset($_SESSION['pruefling_id']))
</td>
<!--<td width="10" class="ItemTesttoolRight" nowrap>&nbsp;</td>-->
</tr>';
if (!$gebiet_pruefling->checkIfExists($_SESSION['pruefling_id'], $row->gebiet_id))
{
$gebiet_pruefling->pruefling_id = $_SESSION['pruefling_id'];
$gebiet_pruefling->gebiet_id = $row->gebiet_id;
$gebiet_pruefling->saveGebietForPruefling();
}
$_SESSION['alleGebiete'][] = $row->gebiet_id;
}
else
+97
View File
@@ -0,0 +1,97 @@
<?php
/* Copyright (C) 2024 Technikum-Wien
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
*/
require_once(dirname(__FILE__).'/basis_db.class.php');
class gebiet_pruefling extends basis_db
{
//Tabellenspalten
public $prueflinggebiet_id;
public $pruefling_id;
public $gebiet_id;
public $insertamum;
public function __construct()
{
parent::__construct();
}
public function loadByPruefling($pruefling_id)
{
$qry = "SELECT *
FROM testtool.tbl_pruefling_gebiet
WHERE pruefling_id = ".$this->db_add_param($pruefling_id, FHC_INTEGER);
if($this->db_query($qry))
{
if($row = $this->db_fetch_object())
{
$this->prueflinggebiet_id = $row->prueflinggebiet_id;
$this->pruefling_id = $row->pruefling_id;
$this->gebiet_id = $row->gebiet_id;
return true;
}
else
{
$this->errormsg = "Pruefling nicht gefunden";
return false;
}
}
else
{
$this->errormsg = "Fehler beim Laden der Gebiete für den Pruefling";
return false;
}
}
public function checkIfExists($pruefling_id, $gebiet_id)
{
$qry = "SELECT 1
FROM testtool.tbl_pruefling_gebiet
WHERE pruefling_id =".$this->db_add_param($pruefling_id, FHC_INTEGER) ." AND gebiet_id =".$this->db_add_param($gebiet_id, FHC_INTEGER);
if ($result = $this->db_query($qry))
{
if ($this->db_num_rows($result) > 0)
return true;
else
return false;
}
}
public function saveGebietForPruefling()
{
$qry = "INSERT INTO testtool.tbl_pruefling_gebiet
(pruefling_id, gebiet_id)
VALUES(".
$this->db_add_param($this->pruefling_id, FHC_INTEGER).','.
$this->db_add_param($this->gebiet_id, FHC_INTEGER).');';
if ($this->db_query($qry))
{
return true;
}
else
{
$this->errormsg = 'Fehler beim Speichern der Daten';
return true;
}
}
}
?>
+17 -1
View File
@@ -853,4 +853,20 @@ html {
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
}
.cis-main-menu {
max-width: 100vw;
}
.cis-main-menu ul {
list-style: none;
margin: 0;
padding: 0;
}
.cis-main-menu ul li {
margin: 0;
padding: 0;
width: 100%;
}
+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;
}
+16 -2
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 }
params: { datum, von, bis, typ, personenanzahl, standort_id, ...advancedFilters }
};
},
getRoomTypes() {
@@ -36,5 +36,19 @@ export default {
url: '/api/frontend/v1/Ort/getTypes',
params: { }
};
},
getStandorte() {
return {
method: 'get',
url: '/api/frontend/v1/Ort/getStandorte',
params: { }
};
},
getAdvancedFilterOptions() {
return {
method: 'get',
url: '/api/frontend/v1/Ort/getAdvancedFilterOptions',
params: { }
};
}
};
+14 -1
View File
@@ -19,6 +19,7 @@ import DeadlineOverview from "../../components/Cis/Abgabetool/DeadlineOverview.j
import Studium from "../../components/Cis/Studium/Studium.js";
import StgOrgLvPlan from "../../components/Cis/LvPlan/StgOrg.js";
import OtherLvPlan from "../../components/Cis/LvPlan/OtherLvPlan.js";
import Compat from "../../components/Cis/Compat.js";
import ApiRouteInfo from '../../api/factory/routeinfo.js';
import {capitalize} from "../../helpers/StringHelpers.js";
@@ -29,6 +30,18 @@ const isMobile = window.matchMedia("(max-width: 767px)").matches;
const router = VueRouter.createRouter({
history: VueRouter.createWebHistory(`/${ciPath}`),
routes: [
{
path: `/Cis/Compat/:mode(ci|legacy)/:path(.*)`,
name: 'Compat',
component: Compat,
props: (route) => {
return {
mode: route.params.mode,
path: route.params.path,
query_string: VueRouter.stringifyQuery(route.query)
};
}
},
{
path: `/Cis/Studium`,
name: 'Studium',
@@ -277,7 +290,7 @@ const app = Vue.createApp({
if (target && this.isInternalRoute(target.href)) {
const url = new URL(target.href)
const path = url.pathname
const path = url.pathname + url.search;
const base = this.$router.options.history.base
const route = path.replace(base, '') || '/'
+93
View File
@@ -0,0 +1,93 @@
export default {
name: "Compat",
props: {
mode: { type: String, required: true },
path: { type: String, required: true },
query_string: { type: String, required: true, default: ''}
},
data: function() {
return {
lastLoadediFrameURL: '',
srcUrl: ''
};
},
computed: {
propsWatchHelper: function() {
return this.mode + '#' + this.path + '#' + this.query_string;
}
},
mounted: function() {
this.srcUrl = this.buildSrcUrl();
},
watch: {
propsWatchHelper: function() {
if(this.lastLoadediFrameURL === '') {
return;
}
let currentiFrameURL = this.$refs.compatiframe ? this.$refs.compatiframe.src : '';
let url = this.buildSrcUrl();
if(this.lastLoadediFrameURL !== url) {
this.srcUrl = url;
if(this.srcUrl === url) {
this.$refs.compatiframe.contentWindow.location.href = url;
}
}
}
},
methods: {
buildSrcUrl: function() {
let url = false;
switch(this.mode) {
case 'ci':
url = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'index.ci.php/' + this.path;
break;
case 'legacy':
url = FHC_JS_DATA_STORAGE_OBJECT.app_root + this.path;
break;
default:
url = false;
}
if(this.query_string !== '' && url) {
url += '?' + this.query_string;
}
return url;
},
loadHandler: function() {
let iframe_href = this.$refs.compatiframe.contentWindow.location.href;
let ci_urlstart = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'index.ci.php/';
let legacy_urlstart = FHC_JS_DATA_STORAGE_OBJECT.app_root;
let routerpath = null;
this.lastLoadediFrameURL = iframe_href;
if(iframe_href.startsWith(ci_urlstart)) {
routerpath = iframe_href.replace(
ci_urlstart, '/Cis/Compat/ci/');
} else if(iframe_href.startsWith(legacy_urlstart)) {
routerpath = iframe_href.replace(
legacy_urlstart, '/Cis/Compat/legacy/');
} else {
return;
}
if(this.$route.fullPath !== routerpath) {
this.$router.push(routerpath);
}
}
},
template: `
<div class="w-100">
<iframe
ref="compatiframe"
v-if="srcUrl"
:src="srcUrl"
@load="loadHandler"
style="width:100%; height:90vh; border:0; display:block;"
></iframe>
<div v-else class="alert alert-warning">Keine URL gefunden.</div>
</div>
`
};
+1 -1
View File
@@ -186,7 +186,7 @@ export default {
</div>
</div>
<nav id="nav-main" class="offcanvas offcanvas-start" tabindex="-1" aria-labelledby="nav-main-btn" data-bs-backdrop="false">
<nav id="nav-main" class="cis-main-menu offcanvas offcanvas-start" tabindex="-1" aria-labelledby="nav-main-btn" data-bs-backdrop="false">
<div id="nav-main-sticky">
<div class="d-flex flex-row h-100">
<div class="offcanvas-body p-0">
+5 -1
View File
@@ -162,6 +162,7 @@ export default {
<div class="btn-group w-100">
<a :target="target"
:href="(entry.menu_open && hasFullLink) ? entry.url : null"
draggable="false"
@click="toggleCollapse"
:class="{
'btn btn-default rounded-0 text-start': true,
@@ -180,12 +181,15 @@ export default {
</div>
<ul ref="children"
class="nav w-100 collapse">
<cis-menu-entry :highestMatchingUrlCount="highestMatchingUrlCount" :activeContent="activeContent" v-for="child in entry.childs" :key="child" :entry="child" :level="level + 1"/>
<li>
<cis-menu-entry :highestMatchingUrlCount="highestMatchingUrlCount" :activeContent="activeContent" v-for="child in entry.childs" :key="child" :entry="child" :level="level + 1"/>
</li>
</ul>
</template>
<a v-else
:href="entry.url"
:target="target"
draggable="false"
:class="{
'btn btn-default rounded-0 w-100 text-start': true,
['btn-level-' + level]: true,
@@ -2,15 +2,16 @@ export default {
props: {
data: {
type: String,
}
},
},
data() {
return {}
return {};
},
template: /*html*/`
template: /*html*/ `
<div class="card">
<div class="card-body">
<span >{{$p.t('profil','fhAusweisStatus',[data])}}</span>
<span v-if="data">{{$p.t('profil','fhAusweisStatus',[data])}}</span>
<span v-else>{{$p.t('profil','fhAusweisStatusNichtAusgegeben')}}</span>
</div>
</div>`,
}
};
+161 -30
View File
@@ -1,11 +1,37 @@
import {CoreFilterCmpt} from "../../../components/filter/Filter.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,
InViewHelp,
InputNumber: primevue.inputnumber,
},
inject: ["isMobile"],
@@ -17,12 +43,16 @@ export const Raumsuche = {
tableBuiltResolve: null,
tableBuiltPromise: null,
roomtypes: null,
defaultType: {
raumtyp_kurzbz: '',
beschreibung: Vue.computed(() => this.$p.t('global/alle'))
},
anzahl: 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(),
@@ -55,7 +85,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')),
@@ -74,8 +109,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
@@ -115,7 +163,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)
@@ -131,21 +184,40 @@ 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
},
search(){
this.loadRooms()
resetAdvancedFilters() {
this.advancedFilters = { ...ADVANCED_DEFAULTS }
},
setRoute(val) {
// TODO: router push
@@ -167,6 +239,8 @@ export const Raumsuche = {
await this.tableBuiltPromise
this.loadRoomTypes()
this.loadStandorte()
this.loadAdvancedFilterOptions()
this.loadRooms()
const tableID = this.tabulatorUuid ? ('-' + this.tabulatorUuid) : ''
@@ -182,14 +256,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">
@@ -235,38 +312,92 @@ 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">
<InputNumber v-model="anzahl"
:prefix="$p.t('rauminfo/minCapacity') + ': '"
inputId="anzahlInput" :min="1" :max="1000"
:style="{'width': '100%'}"
/>
</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>
`,
};
+13 -2
View File
@@ -1,5 +1,6 @@
import BsModal from "../Bootstrap/Modal.js";
import HeightTransition from "../Tranistion/HeightTransition.js";
import MissingPermissionWidget from "../DashboardWidget/MissingPermission.js";
import { enableDragDropTouch } from "../../../../vendor/drag-drop-touch-js/dragdroptouch/dist/drag-drop-touch.esm.min.js";
@@ -112,6 +113,12 @@ export default {
isPinned() {
return this.place?.pinned ? true : false;
},
permitted() {
// widgets without a linked permission (or before the template loaded)
// are considered permitted; the backend sets permitted === false only
// when the user is missing the widget's required permission
return this.widgetTemplate?.permitted !== false;
},
ready() {
return this.component && this.arguments !== null;
}
@@ -169,7 +176,11 @@ export default {
&& this.widgetTemplate.widget_id
&& this.widgetTemplate.arguments
) {
let component = (await import(this.widgetTemplate.setup.file)).default;
// render the "missing permission" screen instead of the actual
// widget when the user does not hold the widget's linked permission
let component = this.permitted
? (await import(this.widgetTemplate.setup.file)).default
: MissingPermissionWidget;
this.$options.components["widget" + this.widgetTemplate.widget_id] = component;
this.component = "widget" + this.widgetTemplate.widget_id;
this.arguments = { ...this.widgetTemplate.arguments, ...this.config };
@@ -269,7 +280,7 @@ export default {
</template>
<!-- widget link -->
<a
v-if="widgetTemplate.setup.cis4link"
v-if="widgetTemplate.setup.cis4link && permitted"
:href="getWidgetC4Link(widgetTemplate)"
class="col-auto ms-auto"
:aria-label="$p.t('dashboard/widget_link')"
@@ -14,10 +14,14 @@ export default {
callbacks: {}
}),
computed: {
// filter widgets away the user has no permissions for
availableWidgets() {
return (this.widgets || []).filter(widget => widget.permitted !== false);
},
hasAnyWidgets() {
if (!this.widgets)
return false;
if (!this.widgets.length && !this.hiddenWidgets?.length)
if (!this.availableWidgets.length && !this.hiddenWidgets?.length)
return false;
return true;
}
@@ -54,7 +58,7 @@ export default {
<template v-slot:default>
<template v-if="widgets && hiddenWidgets">
<div
v-if="!widgets.length && !hiddenWidgets.length"
v-if="!availableWidgets.length && !hiddenWidgets.length"
class="row g-2"
>
<div>{{ $p.t('dashboard/noWidgetsAvailable') }}</div>
@@ -74,16 +78,17 @@ export default {
</div>
</div>
<div
v-if="widgets.length"
v-if="availableWidgets.length"
class="row g-2"
>
<div
v-for="widget in widgets"
<template
v-for="widget in availableWidgets"
:key="widget.widget_id"
class="widget-icon-container col-sm-6 col-md-4 col-lg-3 col-xl-2"
>
<widget-icon @select="pick" :widget="widget"></widget-icon>
</div>
<div v-if="widget.permitted" class="widget-icon-container col-sm-6 col-md-4 col-lg-3 col-xl-2">
<widget-icon @select="pick" :widget="widget"></widget-icon>
</div>
</template>
</div>
</template>
</template>
@@ -0,0 +1,21 @@
import AbstractWidget from './Abstract.js';
/**
* Fallback screen rendered in place of a widget when the current user is missing
* the permission (berechtigung_kurzbz) linked to that widget. Used for widgets
* that are already on a user's dashboard but whose required permission the user
* no longer holds.
*/
export default {
mixins: [
AbstractWidget
],
created() {
this.$emit('setConfig', false);
},
template: /*html*/`
<div class="dashboard-widget-missing-permission d-flex flex-column justify-content-center align-items-center text-center h-100 p-3 text-body-secondary">
<i class="fa-solid fa-lock fa-2x mb-2" aria-hidden="true"></i>
<p class="mb-0">{{ $p.t('dashboard/widget_missing_permission') }}</p>
</div>`
}
@@ -542,10 +542,10 @@ export default {
<template v-if="typeHeader==='mitarbeiter'">
<div class="foto-container col-md-2 d-flex justify-content-start align-items-center w-30 pb-3 gap-3 mt-3 position-relative" style="max-height: 8rem; max-width: 6rem; overflow: hidden;">
<div class="foto-container d-flex flex-column align-items-center h-100 position-relative d-inline-block">
<img
class="d-block w-100 h-100 rounded"
style="height: 84px; object-fit: contain;"
class="d-block rounded"
style="height: 96px;"
alt="Profilbild"
:src="getFotoSrc(headerDataMa.foto)"
/>
@@ -558,15 +558,13 @@ export default {
<template v-if="fotoEditable">
<button
type="button"
class="fotoedit btn btn-outline-dark btn-sm d-flex justify-content-center align-items-center position-absolute start-0"
style="z-index: 104; font-size: 1rem; width: 2.5rem; height: 2.5rem; opacity:0; transition: opacity 0.2s; top:13%;"
class="fotoedit buttonleft btn btn-outline-dark btn-sm d-flex justify-content-center align-items-center position-absolute start-0"
@click="showDeleteModal(person_id)">
<i class="fa fa-xmark"></i>
</button>
<button
type="button"
class="fotoedit btn btn-outline-dark btn-sm d-flex justify-content-center align-items-center position-absolute end-0"
style="z-index: 104; font-size: 1rem; width: 2.5rem; height: 2.5rem; opacity:0; transition: opacity 0.2s; top:13%;"
class="fotoedit buttonright btn btn-outline-dark btn-sm d-flex justify-content-center align-items-center position-absolute end-0"
@click="showModal(person_id)">
<i class="fa fa-pen"></i>
</button>
+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>`
};
+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
}
}
+4 -1
View File
@@ -102,6 +102,8 @@ require_once('dbupdate_3.4/75959_StudVw_Automatische_Tags.php');
require_once('dbupdate_3.4/76160_lvv_favorites.php');
require_once('dbupdate_3.4/77080_tabulator_presets_table.php');
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');
// *** Pruefung und hinzufuegen der neuen Attribute und Tabellen
echo '<H2>Pruefe Tabellen und Attribute!</H2>';
@@ -253,7 +255,7 @@ $tabellen=array(
"hr.tbl_lohnguide_modellfunktion" => array("modellfunktion_kurzbz", "bezeichnung", "jobfamilie_kurzbz", "aktiv", "sort", "insertvon", "insertamum", "updatevon", "updateamum"),
"hr.tbl_lohnguide_modellstelle" => array("modellstelle_kurzbz", "bezeichnung", "grade", "modellfunktion_kurzbz", "aktiv", "sort", "insertvon", "insertamum", "updatevon", "updateamum"),
"hr.tbl_lohnguide_fachrichtung" => array("fachrichtung_kurzbz", "bezeichnung", "aktiv", "insertvon", "insertamum", "updatevon", "updateamum"),
"hr.tbl_vertragsbestandteil_lohnguide" => array("vertragsbestandteil_id", "stellenbezeichnung", "vordienstzeit", "fachrichtung_kurzbz", "modellstelle_kurzbz", "kommentar_person", "kommentar_modellstelle"),
"hr.tbl_vertragsbestandteil_lohnguide" => array("vertragsbestandteil_id", "stellenbezeichnung", "vordienstzeit", "fachrichtung_kurzbz", "modellstelle_kurzbz", "kommentar_person", "kommentar_modellstelle"),
"lehre.tbl_abschlussbeurteilung" => array("abschlussbeurteilung_kurzbz","bezeichnung","bezeichnung_english","sort"),
"lehre.tbl_abschlusspruefung" => array("abschlusspruefung_id","student_uid","vorsitz","pruefer1","pruefer2","pruefer3","abschlussbeurteilung_kurzbz","akadgrad_id","pruefungstyp_kurzbz","datum","uhrzeit","sponsion","anmerkung","updateamum","updatevon","insertamum","insertvon","ext_id","note","protokoll","endezeit","pruefungsantritt_kurzbz","freigabedatum"),
"lehre.tbl_abschlusspruefung_antritt" => array("pruefungsantritt_kurzbz","bezeichnung","bezeichnung_english","sort"),
@@ -421,6 +423,7 @@ $tabellen=array(
"testtool.tbl_pruefling_frage" => array("prueflingfrage_id","pruefling_id","frage_id","nummer","begintime","endtime"),
"testtool.tbl_frage_sprache" => array("frage_id","sprache","text","bild","audio","insertamum","insertvon","updateamum","updatevon"),
"testtool.tbl_vorschlag_sprache" => array("vorschlag_id","sprache","text","bild","audio","insertamum","insertvon","updateamum","updatevon"),
"testtool.tbl_pruefling_gebiet" => array("prueflinggebiet_id","pruefling_id","gebiet_id","insertamum"),
"system.tbl_app" => array("app"),
"system.tbl_appdaten" => array("appdaten_id","uid","app","appversion","version","bezeichnung","daten","freigabe","insertamum","insertvon","updateamum","updatevon"),
"system.tbl_cronjob" => array("cronjob_id","server_kurzbz","titel","beschreibung","file","last_execute","aktiv","running","jahr","monat","tag","wochentag","stunde","minute","standalone","reihenfolge","updateamum", "updatevon","insertamum","insertvon","variablen"),
@@ -0,0 +1,41 @@
<?php
if (! defined('DB_NAME')) exit('No direct script access allowed');
// Creates table public.tbl_notiz_typ if it doesn't exist and grants privileges
if (!$result = @$db->db_query('SELECT 1 FROM testtool.tbl_pruefling_gebiet LIMIT 1'))
{
$qry = 'CREATE TABLE testtool.tbl_pruefling_gebiet
(
prueflinggebiet_id integer NOT NULL,
pruefling_id integer NOT NULL,
gebiet_id integer NOT NULL,
insertamum timestamp DEFAULT now()
);
ALTER TABLE testtool.tbl_pruefling_gebiet OWNER TO fhcomplete;
ALTER TABLE testtool.tbl_pruefling_gebiet ADD CONSTRAINT pk_tbl_pruefling_gebiet PRIMARY KEY (prueflinggebiet_id);
ALTER TABLE testtool.tbl_pruefling_gebiet ADD CONSTRAINT fk_tbl_pruefling_gebiet_pruefling_id FOREIGN KEY (pruefling_id) REFERENCES testtool.tbl_pruefling(pruefling_id) ON UPDATE CASCADE ON DELETE RESTRICT;
ALTER TABLE testtool.tbl_pruefling_gebiet ADD CONSTRAINT fk_tbl_pruefling_gebiet_gebiet_id FOREIGN KEY (gebiet_id) REFERENCES testtool.tbl_gebiet(gebiet_id) ON UPDATE CASCADE ON DELETE RESTRICT;
CREATE SEQUENCE testtool.tbl_pruefling_gebiet_prueflinggebiet_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER TABLE testtool.tbl_pruefling_gebiet_prueflinggebiet_id_seq OWNER TO fhcomplete;
ALTER TABLE testtool.tbl_pruefling_gebiet ALTER COLUMN prueflinggebiet_id SET DEFAULT nextval(\'testtool.tbl_pruefling_gebiet_prueflinggebiet_id_seq\');
GRANT SELECT, UPDATE, INSERT, DELETE ON testtool.tbl_pruefling_gebiet TO web;
GRANT SELECT, UPDATE, INSERT, DELETE ON testtool.tbl_pruefling_gebiet TO vilesci;
GRANT SELECT, UPDATE ON testtool.tbl_pruefling_gebiet_prueflinggebiet_id_seq TO vilesci;
GRANT SELECT, UPDATE ON testtool.tbl_pruefling_gebiet_prueflinggebiet_id_seq TO web;
';
if (!$db->db_query($qry))
echo '<strong>public.tbl_pruefling_gebiet: '.$db->db_last_error().'</strong><br>';
else
echo '<br>public.tbl_pruefling_gebiet table created';
}
@@ -0,0 +1,57 @@
<?php
/* Copyright (C) 2026 fhcomplete.org
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as
* published by the Free Software Foundation; either version 2 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307, USA.
*
* Beschreibung:
* Dashboard Widgets: Verknüpfung einer (einzelnen) Berechtigung pro Widget.
* Neue Spalte dashboard.tbl_widget.berechtigung_kurzbz mit FK auf
* system.tbl_berechtigung. Ist die Spalte NULL, erfordert das Widget keine
* gesonderte Berechtigung. Benutzer denen die verknüpfte Berechtigung fehlt,
* bekommen das Widget nicht mehr zur Auswahl angezeigt bzw. sehen (falls bereits
* am Dashboard) einen "Fehlende Berechtigung"-Screen.
*/
if (! defined('DB_NAME')) exit('No direct script access allowed');
// Add column dashboard.tbl_widget.berechtigung_kurzbz (FK => system.tbl_berechtigung)
if ($result = @$db->db_query("
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'dashboard'
AND table_name = 'tbl_widget'
AND column_name = 'berechtigung_kurzbz';"))
{
if ($db->db_num_rows($result) == 0)
{
$qry = "
ALTER TABLE dashboard.tbl_widget
ADD COLUMN berechtigung_kurzbz VARCHAR(32) NULL;
ALTER TABLE dashboard.tbl_widget
ADD CONSTRAINT tbl_widget_berechtigung_fk
FOREIGN KEY (berechtigung_kurzbz)
REFERENCES system.tbl_berechtigung (berechtigung_kurzbz)
ON UPDATE CASCADE ON DELETE SET NULL;";
if (!$db->db_query($qry))
{
echo '<strong>dashboard.tbl_widget: '.$db->db_last_error().'</strong><br>';
}
else
{
echo 'dashboard.tbl_widget: Spalte berechtigung_kurzbz (FK system.tbl_berechtigung) hinzugefuegt<br>';
}
}
}
+378 -220
View File
@@ -19829,6 +19829,26 @@ array(
)
)
),
array(
'app' => 'international',
'category' => 'international',
'phrase' => 'massnahmeneinmalig',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Diese Maßnahme ist nur einmalig planbar.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'This measure may only be planned once.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'international',
'category' => 'international',
@@ -20609,6 +20629,26 @@ array(
)
)
),
array(
'app' => 'international',
'category' => 'international',
'phrase' => 'personenMassnahmen',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'alle Maßnahmen der Personen',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => ' all measures of the people',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'international',
'category' => 'international',
@@ -20689,6 +20729,46 @@ array(
)
)
),
array(
'app' => 'international',
'category' => 'international',
'phrase' => 'einmalig',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Nur einmal planbar',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'One-time only',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'international',
'category' => 'international',
'phrase' => 'erroreinmalig',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Diese Maßnahme kann nur einmal geplant werden',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'This measure can only be planned once.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'gruppenmanagement',
@@ -21590,6 +21670,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',
@@ -29363,6 +29701,26 @@ array(
)
)
),
array(
'app' => 'core',
'category' => 'profil',
'phrase' => 'fhAusweisStatusNichtAusgegeben',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Der FH Ausweis wurde noch nicht ausgegeben.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The FH ID card has not been issued yet.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'profil',
@@ -35700,226 +36058,6 @@ array(
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorEntUploadFileTooLarge',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Datei ist {size} MB groß. Die maximale Dateigröße ist {max} MB. Verkleinern Sie die Datei und laden Sie sie erneut hoch. Fotografieren Sie das Dokument zum Beispiel mit geringerer Auflösung oder komprimieren Sie das PDF.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The file is {size} MB. The maximum file size is {max} MB. Make the file smaller and upload it again. For example, take the photo of the document with a lower resolution or compress the PDF.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorEntUploadFiletype',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Der Dateityp von »{file}« ist nicht erlaubt. Erlaubte Dateitypen: {filetypes}. Speichern Sie die Datei in einem erlaubten Dateityp und laden Sie sie erneut hoch.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The file type of "{file}" is not allowed. Allowed file types: {filetypes}. Save the file in an allowed file type and upload it again.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorEntUploadContent',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Der Server kann »{file}« nicht als {type}-Datei lesen. Die Datei ist beschädigt oder hat eine falsche Dateiendung. Öffnen Sie die Datei und speichern Sie sie erneut in einem dieser Dateitypen: {filetypes}.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The server cannot read "{file}" as a {type} file. The file is damaged or has an incorrect file extension. Open the file and save it again in one of these file types: {filetypes}.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorEntUploadEmptyFile',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Die Datei »{file}« ist leer. Wählen Sie eine andere Datei aus.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The file "{file}" is empty. Select a different file.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorEntUploadPartial',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Der Server hat die Datei nicht vollständig erhalten. Prüfen Sie Ihre Internetverbindung und laden Sie die Datei erneut hoch.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The server did not receive the complete file. Check your internet connection and upload the file again.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorEntUploadNoFile',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Der Server hat keine Datei erhalten. Wählen Sie die Datei noch einmal aus und laden Sie sie erneut hoch.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The server did not receive a file. Select the file again and upload it.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorEntUploadTechnical',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Der Server kann die Datei wegen eines technischen Problems nicht speichern. Versuchen Sie es später erneut. Wenn der Fehler bleibt, wenden Sie sich an den Support und nennen Sie die Fehlernummer {reference}.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'The server cannot save the file because of a technical problem. Try again later. If the error continues, contact the support team and give them the error number {reference}.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorEntAlreadyAccepted',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Diese Entschuldigung hat bereits den Status »akzeptiert«. Sie müssen kein Dokument mehr nachreichen.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'This excuse note already has the status "accepted". You do not have to upload a document.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'errorEntAlreadyDeclined',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Diese Entschuldigung hat bereits den Status »abgelehnt«. Sie können kein Dokument mehr nachreichen. Wenn Sie Fragen haben, wenden Sie sich an Ihre Studiengangsassistenz.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'This excuse note already has the status "declined". You cannot upload a document for it. If you have questions, contact your study program assistant.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'entUploadAllowedFiletypes',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Erlaubte Dateitypen: {filetypes}.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Allowed file types: {filetypes}.',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'anwesenheiten',
'category' => 'global',
'phrase' => 'entUploadMaxFilesize',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Maximale Dateigröße: {max} MB.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Maximum file size: {max} MB.',
'description' => '',
'insertvon' => 'system'
)
)
),
//
// DIGITALE ANWESENHEITEN PHRASEN END
//
@@ -49740,6 +49878,26 @@ array(
)
)
),
array(
'app' => 'core',
'category' => 'dashboard',
'phrase' => 'widget_missing_permission',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Ihnen fehlt die erforderliche Berechtigung, um dieses Widget anzuzeigen.',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'You do not have the permission required to display this widget.',
'description' => '',
'insertvon' => 'system'
)
)
),
// CIS4 phrases from legacy code end
// FHC4 Phrases Abschlusspruefung
array(
+55 -2
View File
@@ -251,6 +251,31 @@ if ($deleteSingleResult)
exit();
}
$qry = "SELECT * FROM testtool.tbl_pruefling_gebiet
WHERE pruefling_id=" . $db->db_add_param($pruefling->pruefling_id) . " AND gebiet_id=" . $db->db_add_param($_POST['gebiet_id']) . "";
if ($db->db_query($qry))
{
while ($row = $db->db_fetch_object())
{
$undo .= " INSERT INTO testtool.tbl_pruefling_gebiet(prueflinggebiet_id,pruefling_id,gebiet_id, insertamum) VALUES (" .
$db->db_add_param($row->prueflinggebiet_id, FHC_INTEGER) . ', ' .
$db->db_add_param($row->pruefling_id, FHC_INTEGER) . ', ' .
$db->db_add_param($row->gebiet_id, FHC_INTEGER) . ', ' .
$db->db_add_param($row->insertamum) . ');';
}
}
else
{
$db->db_query('ROLLBACK');
echo json_encode(array(
'status' => 'fehler',
'msg' => 'Fehler beim Erstellen des UNDO Befehls fuer testtool.tbl_pruefling_gebiet'
));
exit();
}
//Antworten loeschen
$qry = "DELETE FROM testtool.tbl_pruefling_frage where pruefling_id=" . $db->db_add_param($pruefling->pruefling_id, FHC_INTEGER) . " AND
frage_id IN (SELECT frage_id FROM testtool.tbl_frage WHERE gebiet_id=" . $db->db_add_param($_POST['gebiet_id']) . ");
@@ -258,7 +283,9 @@ if ($deleteSingleResult)
DELETE FROM testtool.tbl_antwort
WHERE pruefling_id=" . $db->db_add_param($pruefling->pruefling_id) . " AND
vorschlag_id IN (SELECT vorschlag_id FROM testtool.tbl_vorschlag WHERE frage_id IN
(SELECT frage_id FROM testtool.tbl_frage WHERE gebiet_id=" . $db->db_add_param($_POST['gebiet_id']) . "));";
(SELECT frage_id FROM testtool.tbl_frage WHERE gebiet_id=" . $db->db_add_param($_POST['gebiet_id']) . "));
DELETE FROM testtool.tbl_pruefling_gebiet where pruefling_id=" . $db->db_add_param($pruefling->pruefling_id, FHC_INTEGER) . " AND gebiet_id=" . $db->db_add_param($_POST['gebiet_id']) . ";";
if ($result = $db->db_query($qry))
{
@@ -388,9 +415,35 @@ if ($deleteAllResults)
exit();
}
$qry = "SELECT * FROM testtool.tbl_pruefling_gebiet WHERE pruefling_id=" . $db->db_add_param($pruefling->pruefling_id, FHC_INTEGER) . ";
";
if ($db->db_query($qry))
{
while ($row = $db->db_fetch_object())
{
$undo .= " INSERT INTO testtool.tbl_pruefling_gebiet(prueflinggebiet_id,pruefling_id,gebiet_id,insertamum) VALUES (" .
$db->db_add_param($row->prueflinggebiet_id, FHC_INTEGER) . ', ' .
$db->db_add_param($row->pruefling_id, FHC_INTEGER) . ', ' .
$db->db_add_param($row->gebiet_id, FHC_INTEGER) . ', ' .
$db->db_add_param($row->insertamum) . ');';
}
}
else
{
$db->db_query('ROLLBACK');
echo json_encode(array(
'status' => 'fehler',
'msg' => 'Fehler beim Erstellen des UNDO Befehls fuer testtool.tbl_pruefling_gebiet'
));
exit();
}
//Antworten loeschen
$qry = " DELETE FROM testtool.tbl_pruefling_frage where pruefling_id=".$db->db_add_param($pruefling->pruefling_id).";
DELETE FROM testtool.tbl_antwort WHERE pruefling_id=".$db->db_add_param($pruefling->pruefling_id).";";
DELETE FROM testtool.tbl_antwort where pruefling_id=".$db->db_add_param($pruefling->pruefling_id).";
DELETE FROM testtool.tbl_pruefling_gebiet WHERE pruefling_id=".$db->db_add_param($pruefling->pruefling_id).";";
if ($result = $db->db_query($qry))
{
@@ -402,7 +402,10 @@ if(isset($_POST['deleteteilgebiet']))
DELETE FROM testtool.tbl_antwort
WHERE pruefling_id=".$db->db_add_param($pruefling->pruefling_id)." AND
vorschlag_id IN (SELECT vorschlag_id FROM testtool.tbl_vorschlag WHERE frage_id IN
(SELECT frage_id FROM testtool.tbl_frage WHERE gebiet_id=".$db->db_add_param($_POST['gebiet'])."));";
(SELECT frage_id FROM testtool.tbl_frage WHERE gebiet_id=".$db->db_add_param($_POST['gebiet'])."));
DELETE FROM testtool.tbl_pruefling_gebiet
WHERE pruefling_id=".$db->db_add_param($pruefling->pruefling_id)." AND gebiet_id=".$db->db_add_param($_POST['gebiet']).";";
if($result = $db->db_query($qry))
{
@@ -497,7 +500,8 @@ if(isset($_POST['delete_all']))
}
//Gebiet loeschen
$qry = "DELETE FROM testtool.tbl_pruefling_frage where pruefling_id=".$db->db_add_param($pruefling->pruefling_id).";
DELETE FROM testtool.tbl_antwort WHERE pruefling_id=".$db->db_add_param($pruefling->pruefling_id).";";
DELETE FROM testtool.tbl_antwort WHERE pruefling_id=".$db->db_add_param($pruefling->pruefling_id).";
DELETE FROM testtool.tbl_pruefling_gebiet WHERE pruefling_id=".$db->db_add_param($pruefling->pruefling_id).";";
if($result = $db->db_query($qry))
{