Compare commits

...
Author SHA1 Message Date
Johann Hoffmann 57d37e01a3 only make those anw_user_entries editable which come from LE's the user is allowed to change by isAdminOrTeachesLE function; also send the correct le_id of the different le_ids of detail component to backend, not the selected one of main view; simplify the entschuldigungsmanagement filter options and only have the "alle offenen Entschuldigungen unabhängig vom gewählten Zeitraum" button available which comes with a comprehensive tooltip in simple language what that button actually does since it can be confusing to interpret which timespan and which dataset is being delivered here. 💯💥 2026-10-08 16:22:28 +02:00
Johann Hoffmann 0428c27bef fix delete dialog handling; new multiselect mode is now hidden behind a "Gesamtansicht" Button Switch; in Gesamtansicht remove Kontrolldatum select & Alle anzeigen and default to all LV-Teile selected; fix multiselect darkmode theme; align all ? tooltips to be the same component and enhanced that component with delayed sticky animation, then moved that to the core so we can ALL have fancy sticky tooltips; checked over existing phrasen for spelling mistakes; bunch more css adjustments; fixed a possible bug in getAnwForUid where only uid was verified and not person_id; renamed timeline tab and enabled it for students aswell -> configurable; 🤓; 2026-10-07 19:10:43 +02:00
Johann Hoffmann b4fc8dfd45 status entry in legende for missing entry aka "-" 2026-10-01 12:03:34 +02:00
Johann Hoffmann 614274f3e1 darkmode stylecheck & adjustments across anw extension; ent management UX changes: datepicker labels, counts for filtered/total/offen/akzeptiert/abgelehnt + a button to just load all open excuse note applications no matter the timespan; added more clear visualization for multi stg students inside student uid col, studiengang col, orgform col and edit entschuldigung modal; fixed a bug in edit entschuldigung modal never showing to upload datum oopsie; 2026-09-30 15:07:45 +02:00
Johann Hoffmann c82a6e4c44 phrasens 2026-09-28 13:31:13 +02:00
Johann Hoffmann 791ae73627 Merge branch 'master' into feature-77588/AnwesenheitenSprint252 2026-09-28 12:35:40 +02:00
Johann Hoffmann 283865aa32 Merge remote-tracking branch 'origin/feature-76994/EntEditByStudentErrorFix' into feature-77588/AnwesenheitenSprint252
# Conflicts:
#	system/phrasesupdate.php
2026-09-28 11:26:11 +02:00
Johann Hoffmann 2bb59c9f66 fix a tabulator issue with dynamic columns and presets; legacy le selection now also uses the legacy loading procedure instead of the new one fetching all le of the lva, with the flag on only the assigned le to the looged in lektor; added phrasen; 2026-09-25 13:47:51 +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
Johann Hoffmann 86a8350bdc fix phrasesupdate.php missing colon 2026-08-27 09:32:59 +02:00
Johann Hoffmann 943999cef9 adapt filter/tablePresets/tablePresetsModule to account for dynamic tables with a new presetColumns property. by default the preset holds all columns, if a scope is given it only holds those columns, their header filters and their sort; on apply the scope is merged into the current table columns so the dynamic ones keep their place; 2026-08-26 16:45:06 +02:00
Johann Hoffmann 6d9ce91302 Merge remote-tracking branch 'origin/master' into feature-77588/AnwesenheitenSprint252
# Conflicts:
#	system/phrasesupdate.php
2026-08-26 16:31:28 +02:00
Johann Hoffmann 7e01142016 anw widget sql scripts for standalone linklist and once utilizing fhc-calendar list view; also added anw link in lehreinheiten renderer modalContent as new row 2026-07-21 18:30:24 +02:00
Johann Hoffmann 04d90f53d3 adapt stundeplanlib expandObjectInformation when called by getEventsEmployee with an event called extendStundenplanData, which allows extensions like digi anw to plant their own data object inside the stundenplan events in order to conditionally render something like currently in the lehreinheiten calendarEvent.js renderer -> parameterized link to anw extension in order to avoid duplicating functionality across widgets when there is already one doing it very well; 2026-07-20 16:40:57 +02:00
Johann Hoffmann 7fdfa1481e new phrases 2026-07-09 16:00:42 +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
Johann Hoffmann 285a2921e4 code cleanup & refactoring lektor frontend/backend; transactions on important concurrent operations like deleteAnwesenheitskontrolle & getNewQRCode; show all flag saved/fetch from local storage; hardcoded strings -> phrasen; 2026-07-03 14:23:14 +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
26 changed files with 3300 additions and 138 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 '';
}
+17 -2
View File
@@ -7,6 +7,7 @@ use \DateTime as DateTime;
use \DateTimeZone as DateTimeZone;
use \DateInterval as DateInterval;
use \DatePeriod as DatePeriod;
use CI3_Events as Events;
class StundenplanLib
{
@@ -143,7 +144,7 @@ class StundenplanLib
return $stundenplan_data;
$stundenplan_data = getData($stundenplan_data) ?? [];
$function_error = $this->expandObjectInformation($stundenplan_data);
$function_error = $this->expandObjectInformation($stundenplan_data, true);
if ($function_error)
return $function_error;
@@ -303,7 +304,7 @@ class StundenplanLib
return success($lektoren);
}
public function expandObjectInformation($data)
public function expandObjectInformation($data, $callExtensionsEvent = false)
{
$this->_ci =& get_instance();
@@ -392,6 +393,20 @@ class StundenplanLib
$item->lektor = $lektor_obj_array;
}
// info retrieved from extensions might not be necessary for some stundenplan use cases so the event
// will be gated behind this flag
if($callExtensionsEvent) {
Events::trigger(
'extendStundenplanData',
function & () use (&$data)
{
return $data;
}
);
}
}
public function fetchFerienTageEvents($start_date, $end_date, $studiengang_kz)
+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;
}
}
}
?>
+3
View File
@@ -840,8 +840,11 @@ html {
color: var(--fhc-light);
}
/* the bootstrap opacity 0.5 reaches 2.9:1 on the blue header, 0.75 reaches 4.5:1 (WCAG 1.4.11) */
#cis-main .modal-header .btn-close{
filter: invert(1);
--bs-btn-close-opacity: 0.75;
--bs-btn-close-hover-opacity: 1;
}
#cis-main .modal-footer {
+22 -1
View File
@@ -121,8 +121,9 @@
background-color: #000;
}
/* contrast: #777 reaches 4.48:1 on the odd and 4:1 on the even rows (#f2f2f2), #6c6c6c 5.3:1 and 4.7:1 */
.tabulator-row.tabulator-unselectable:not(.tabulator-calcs) {
color: #777 !important;
color: #6c6c6c !important;
}
.tabulator-cell .btn {
@@ -156,3 +157,23 @@
color: #fff;
background: #1d68cd;
}
/* contrast (WCAG 1.4.11): tabulator_simple draws the inactive sort arrow in #bbb (1.9:1) and the
* placeholder in #ccc (1.6:1). #8f8f8f (3.2:1) is close to the grey of the active arrow, so the
* active arrow gets the primary color (6.8:1) */
.tabulator .tabulator-header .tabulator-col .tabulator-col-content .tabulator-col-sorter .tabulator-arrow,
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="none"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: #8f8f8f;
}
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: var(--fhc-primary, #006095);
}
.tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-top-color: var(--fhc-primary, #006095);
}
.tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents {
color: #6c757d;
}
+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;
}
+154 -3
View File
@@ -188,6 +188,9 @@
--fhc-light: var(--fhc-white-50);
--fhc-cis-menu-lvl-1-color: var(--fhc-white-50);
/* input borders, 3:1 against the background and the tinted card headers (#f8f8f9) */
--fhc-input-border: #868e96;
}
:root.dark {
@@ -233,6 +236,8 @@
--fhc-dark: var(--fhc-black-50);
--fhc-light: var(--fhc-white-70);
/* input borders, 3:1 against the background */
--fhc-input-border: #7c8791;
}
:root.purple {
@@ -313,12 +318,13 @@ html.dark .border-primary {
border-color: var(--fhc-primary) !important;
}
/* fhc danger*/
/* fhc danger: --fhc-danger is a light red for text and borders on the dark background. White text
* on it reaches 1.2:1, the backgrounds use the dark red (6.5:1) */
html.dark .bg-danger,
html.dark .btn-danger,
html.dark .btn-outline-danger {
background-color: var(--fhc-danger) !important;
border-color: var(--fhc-danger) !important;
background-color: var(--fhc-red-60) !important;
border-color: var(--fhc-red-60) !important;
color: white;
}
@@ -327,6 +333,25 @@ html.dark .btn-danger:hover {
filter: brightness(90%);
}
/* the dark primevue theme draws p-button-danger (e.g. FhcAlert.confirmDelete) light pink with black text,
* use the dark red of .btn-danger */
html.dark .p-button.p-button-danger {
color: white;
background: var(--fhc-red-60);
border-color: var(--fhc-red-60);
}
html.dark .p-button.p-button-danger:enabled:hover,
html.dark .p-button.p-button-danger:enabled:active {
color: white;
background: var(--fhc-red-70);
border-color: var(--fhc-red-70);
}
html.dark .p-button.p-button-danger:enabled:focus {
box-shadow: 0 0 0 0.2rem rgba(225, 110, 120, 0.5);
}
html.dark .text-danger {
color: var(--fhc-danger) !important;
}
@@ -335,6 +360,55 @@ html.dark .border-danger {
border-color: var(--fhc-danger) !important;
}
/* bootstrap draws btn-outline-secondary in #6c757d: 4.3:1 on the light dialog footer (#f6f6f6).
* --fhc-gray-30 reaches 5.4:1 there, the white hover text 5.9:1 */
html.light .btn-outline-secondary {
--bs-btn-color: var(--fhc-gray-30);
--bs-btn-border-color: var(--fhc-gray-30);
--bs-btn-hover-bg: var(--fhc-gray-30);
--bs-btn-hover-border-color: var(--fhc-gray-30);
--bs-btn-active-bg: var(--fhc-gray-30);
--bs-btn-active-border-color: var(--fhc-gray-30);
}
/* bootstrap keeps #6c757d for btn-outline-secondary in the dark theme: 2.4:1 on the dark tables and dialogs */
html.dark .btn-outline-secondary {
--bs-btn-color: var(--fhc-white-90);
--bs-btn-border-color: var(--fhc-white-90);
--bs-btn-hover-color: var(--fhc-black-30);
--bs-btn-hover-bg: var(--fhc-white-90);
--bs-btn-hover-border-color: var(--fhc-white-90);
--bs-btn-active-color: var(--fhc-black-30);
--bs-btn-active-bg: var(--fhc-white-90);
--bs-btn-active-border-color: var(--fhc-white-90);
}
/* the primevue multiselect renders the check icon also in unchecked boxes and hides it by color:
* white on white in the light theme, but #151515 on #20262e in the dark theme */
html.dark .p-checkbox .p-checkbox-box:not(.p-highlight) .p-checkbox-icon {
color: transparent;
}
/* FhcAlert.confirm passes bootstrap classes to the primevue buttons. The primevue theme loads after
* bootstrap, its .p-button colors replace the .btn colors: every button blue */
.p-button.btn {
color: var(--bs-btn-color);
background: var(--bs-btn-bg);
border-color: var(--bs-btn-border-color);
}
.p-button.btn:enabled:hover {
color: var(--bs-btn-hover-color);
background: var(--bs-btn-hover-bg);
border-color: var(--bs-btn-hover-border-color);
}
.p-button.btn:enabled:active {
color: var(--bs-btn-active-color);
background: var(--bs-btn-active-bg);
border-color: var(--bs-btn-active-border-color);
}
html.dark .tabulator,
html.dark .tabulator .tabulator-header,
@@ -355,6 +429,52 @@ html.dark .tabulator .tabulator-header .tabulator-col:hover {
background-color: var(--fhc-tertiary) !important;
}
/* the active sort arrow of tabulator_simple (#666) reaches 2:1 on the dark header */
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="ascending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-bottom-color: var(--fhc-text);
}
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable[aria-sort="descending"] .tabulator-col-content .tabulator-col-sorter .tabulator-arrow {
border-top-color: var(--fhc-text);
}
@media (hover: hover) and (pointer: fine) {
html.dark .tabulator .tabulator-header .tabulator-col.tabulator-sortable .tabulator-col-content .tabulator-col-sorter.tabulator-col-sorter-element .tabulator-arrow:hover {
border-bottom-color: var(--fhc-text);
border-top-color: var(--fhc-text);
}
}
/* the group toggle arrow of tabulator_simple (#666) reaches 2:1 on the dark rows */
html.dark .tabulator .tabulator-row.tabulator-group:not(.tabulator-group-visible) .tabulator-arrow {
border-left-color: var(--fhc-text);
}
html.dark .tabulator .tabulator-row.tabulator-group.tabulator-group-visible .tabulator-arrow {
border-top-color: var(--fhc-text);
}
/* the footer stays white otherwise: the page buttons get the white button text of the dark color scheme */
html.dark .tabulator .tabulator-footer {
background-color: var(--fhc-tertiary);
border-top-color: var(--fhc-border);
color: var(--fhc-text);
}
html.dark .tabulator .tabulator-footer .tabulator-page {
background: transparent;
border-color: var(--fhc-white-90);
color: var(--fhc-text);
}
html.dark .tabulator .tabulator-footer .tabulator-page.active {
color: var(--fhc-red-30);
}
html.dark .tabulator .tabulator-tableholder .tabulator-placeholder .tabulator-placeholder-contents {
color: var(--fhc-white-90);
}
.p-paginator{
background-color: var(--fhc-background) !important;
}
@@ -376,4 +496,35 @@ html.dark .tabulator .tabulator-header .tabulator-col:hover {
.p-link:focus {
box-shadow: 0 0 0 0.2rem rgba(var(--fhc-primary), 0.5) !important;
}
/* input borders: 3:1 against the background (WCAG 1.4.11), the bootstrap, primevue and datepicker defaults
* reach 1.3 - 1.9:1. Focus and checked states of the libraries stay */
.form-control,
.form-select,
.form-check-input,
.p-inputtext,
.p-inputtext:enabled:hover:not(:focus),
.p-dropdown,
.p-dropdown:not(.p-disabled):hover:not(.p-focus),
.p-multiselect,
.p-multiselect:not(.p-disabled):hover:not(.p-focus),
.p-checkbox .p-checkbox-box,
.p-radiobutton .p-radiobutton-box {
border-color: var(--fhc-input-border, #868e96);
}
/* the datepicker css loads after the theme, html raises the specificity */
html .dp__theme_light,
html .dp__theme_dark {
--dp-border-color: var(--fhc-input-border, #868e96);
--dp-border-color-hover: var(--fhc-border-highlight, #495057);
}
/* the light datepicker (also in the dark cis4 theme) draws the days of other months in #c0c4cc (1.8:1),
* the icons in #959595 (3:1) and the selected day in #f8f5f5 on #1976d2 (4.2:1) */
html .dp__theme_light {
--dp-secondary-color: #6c6c6c;
--dp-icon-color: #6c757d;
--dp-primary-text-color: #fff;
}
+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, '') || '/'
+2 -2
View File
@@ -73,6 +73,7 @@ export default {
}
},
computed: {
// opacity 0.5 reaches 2.4:1 on the blue cis4 modal header, 0.75 reaches 3.6:1 (WCAG 1.4.11)
getExpandButtonStyles() {
const hovered = this.expandBtnHovered;
const focused = this.expandBtnFocused;
@@ -85,12 +86,11 @@ export default {
border: 0;
background: transparent;
font-size: 1em;
opacity: 0.5;
color: inherit;
cursor: pointer;
line-height: 1;
transition: opacity 0.15s ease;
opacity: ${focused ? '1' : hovered ? '0.75' : '0.5'};
opacity: ${focused || hovered ? '1' : '0.75'};
outline: ${focused ? '1px solid currentColor' : 'none'};
outline-offset: 2px;`
}
+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>
`
};
@@ -66,6 +66,24 @@ export default {
return "d-none d-xl-grid";
}
},
// link into the FHC-Core-Anwesenheiten tool
// digi_anw_data is attached per event by the extension's `extendStundenplanData` listener
anwLink() {
const d = this.event && this.event.digi_anw_data;
if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz)
return null;
const base = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/extensions/FHC-Core-Anwesenheiten/';
return base
+ `?stg_kz=${encodeURIComponent(d.stg_kz)}`
+ `&sem=${encodeURIComponent(d.sem ?? '')}`
+ `&lvid=${encodeURIComponent(d.lv_id)}`
+ `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`;
},
anwLinkTitle() {
return this.$p.t('global/anwKontrolleOeffnen');
},
},
template: /*html*/`
<div
@@ -96,6 +114,21 @@ export default {
... +{{ event.lektor.length - 3 }}
</span>
</div>
<a
v-if="anwLink"
:href="anwLink"
target="_blank"
rel="noopener"
class="d-flex align-items-center flex-shrink-0"
style="margin-right: 4px"
:title="anwLinkTitle"
:aria-label="anwLinkTitle"
draggable="false"
@click.stop
@mousedown.stop
>
<i class="fa fa-arrow-up-right-from-square fs-5" aria-hidden="true"></i>
</a>
</div>
`,
}
@@ -49,6 +49,20 @@ export default {
return this.event.ende;
}
return numberPadding(this.event.ende.getHours()) + ":" + numberPadding(this.event.ende.getMinutes());
},
// Deep link into the attendance tool; only present when the anw extension wrote into digi_anw_data
anwLink: function () {
const d = this.event && this.event.digi_anw_data;
if (!d || !d.stg_kz || !d.lv_id || !d.sem_kurzbz)
return null;
const base = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/extensions/FHC-Core-Anwesenheiten/';
return base
+ `?stg_kz=${encodeURIComponent(d.stg_kz)}`
+ `&sem=${encodeURIComponent(d.sem ?? '')}`
+ `&lvid=${encodeURIComponent(d.lv_id)}`
+ `&sem_kurzbz=${encodeURIComponent(d.sem_kurzbz)}`;
}
},
watch: {
@@ -160,6 +174,16 @@ export default {
}}</th>
<td>{{event.organisationseinheit}}</td>
</tr>
<tr v-if="anwLink">
<th>{{
$p.t('global','anwKontrolle')?
$p.t('global','anwKontrolle')+':'
:''
}}</th>
<td>
<a :href="anwLink" target="_blank" rel="noopener" :aria-label="$p.t('global','anwKontrolleOeffnen')" :title="$p.t('global','anwKontrolleOeffnen')"><i class="fa fa-arrow-up-right-from-square me-1" style="color:#00649C" aria-hidden="true"></i></a>
</td>
</tr>
</tbody>
</table>
<lv-menu v-if="lvMenu.length && $route.name === 'MyLvPlan'" :containerStyles="['p-0']" :rowStyles="['m-0']" :menu="lvMenu" />
@@ -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>`
};
+20 -6
View File
@@ -106,6 +106,13 @@ export const CoreFilterCmpt = {
type: Array,
default: [],
},
// fields of the columns a preset is allowed to handle. Give the constant
// columns here if the table builds dynamic columns at runtime. An empty
// array gives the preset every column of the table.
presetColumns: {
type: Array,
default: [],
},
},
data: function () {
return {
@@ -942,6 +949,10 @@ export const CoreFilterCmpt = {
return res;
}, {});
},
isColumnInPresetScope(columnField) {
if (!this.$props.presetColumns.length) return true;
return this.$props.presetColumns.includes(columnField);
},
afterTablePresetApplied(preset) {
this.selectedFields = [...preset.displayedColumns];
this.appliedPreset = { ...preset };
@@ -973,6 +984,7 @@ export const CoreFilterCmpt = {
const displayedColumns = this.tabulator.columnManager.columns
.filter((column) => column.field !== "collapse")
.filter((column) => this.isColumnInPresetScope(column.field))
.filter((column) => column.visible)
.map((column) => column.field);
@@ -1029,12 +1041,13 @@ export const CoreFilterCmpt = {
});
const activeSort = this.tabulator.getSorters()[0];
const sort = activeSort
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
const sort =
activeSort && this.isColumnInPresetScope(activeSort.field)
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
if (
sort?.column !== this.appliedPreset.sort?.column ||
sort?.direction !== this.appliedPreset.sort?.direction
@@ -1148,6 +1161,7 @@ export const CoreFilterCmpt = {
:presetsId="$props.presetsId"
:tabulator="tabulator"
:generalPresets="$props.generalPresets"
:presetColumns="$props.presetColumns"
ref="tablePresets"
class="card-body collapse"
></table-presets>
@@ -26,6 +26,9 @@ export default {
presetsId: { type: String },
tabulator: { type: Object },
generalPresets: { type: Array, default: [] },
// fields of the columns the preset is allowed to handle. An empty array
// gives the preset every column of the table.
presetColumns: { type: Array, default: [] },
},
emits: ["tablePresetApplied"],
data: function () {
@@ -162,6 +165,10 @@ export default {
},
},
methods: {
isInPresetScope(columnField) {
if (!this.$props.presetColumns.length) return true;
return this.$props.presetColumns.includes(columnField);
},
getPresetIndex() {
return this.presetIndexBeingApplied;
},
@@ -227,9 +234,9 @@ export default {
}
},
generateNewPreset() {
const columns = this.tabulator.columnManager.columns.filter(
(column) => column.field !== "collapse",
);
const columns = this.tabulator.columnManager.columns
.filter((column) => column.field !== "collapse")
.filter((column) => this.isInPresetScope(column.field));
const displayedColumns = columns
.filter((column) => column.visible)
@@ -251,12 +258,13 @@ export default {
);
const activeSort = this.tabulator.getSorters()[0];
const sort = activeSort
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
const sort =
activeSort && this.isInPresetScope(activeSort.field)
? {
column: activeSort.field,
direction: activeSort.dir,
}
: null;
this.newPreset = {
displayedColumns,
@@ -325,8 +333,11 @@ export default {
preset.displayedColumns.length;
preset.displayedColumns = preset.displayedColumns.filter(
(columnField) => {
return validColumns.some(
(column) => column.field === columnField,
return (
this.isInPresetScope(columnField) &&
validColumns.some(
(column) => column.field === columnField,
)
);
},
);
@@ -365,10 +376,11 @@ export default {
if (preset.sort) {
if (
!this.isInPresetScope(preset.sort.column) ||
!validColumns.some(
(column) =>
column.field === preset.sort.column &&
column.headerSort,
column.headerSort !== false,
)
) {
preset.sort = null;
@@ -407,7 +419,10 @@ export default {
window.localStorage["tabulatorPreset-" + this.$props.presetsId] =
preset.id ? "custom:" + preset.id : "general:" + preset.name;
this.$props.tabulator.modules.tablePresets.applyPreset(preset);
this.$props.tabulator.modules.tablePresets.applyPreset(
preset,
this.$props.presetColumns,
);
this.$emit("tablePresetApplied", { preset });
+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
}
}
@@ -8,27 +8,36 @@ export default class TablePresetsModule extends Module {
super(table);
}
applyPreset(preset) {
let columnsConfig = this.table.options.columns;
let reorderedColumnsConfig = [];
preset.displayedColumns.forEach((columnField) => {
let columnConfig = columnsConfig.find(
(column) => column.field === columnField,
);
if (!columnConfig) return;
columnConfig.visible = true;
reorderedColumnsConfig.push(columnConfig);
});
let hiddenColumnsConfig = columnsConfig
.filter((column) => !preset.displayedColumns.includes(column.field))
.map((column) => {
column.visible = false;
return column;
// presetColumns limits the preset to the given column fields. A table with dynamic
// columns gives the fields of its constant columns here.
applyPreset(preset, presetColumns = []) {
if (presetColumns.length) {
this.applyColumnsInScope(preset, presetColumns);
} else {
let columnsConfig = this.table.options.columns;
let reorderedColumnsConfig = [];
preset.displayedColumns.forEach((columnField) => {
let columnConfig = columnsConfig.find(
(column) => column.field === columnField,
);
if (!columnConfig) return;
columnConfig.visible = true;
reorderedColumnsConfig.push(columnConfig);
});
this.table.setColumns(
reorderedColumnsConfig.concat(hiddenColumnsConfig),
);
let hiddenColumnsConfig = columnsConfig
.filter(
(column) =>
!preset.displayedColumns.includes(column.field),
)
.map((column) => {
column.visible = false;
return column;
});
this.table.setColumns(
reorderedColumnsConfig.concat(hiddenColumnsConfig),
);
}
this.table.clearHeaderFilter();
if (preset.headerFilters) {
@@ -61,4 +70,44 @@ export default class TablePresetsModule extends Module {
);
}
}
// The preset orders and hides the columns of the scope only. A column outside the
// scope keeps its position, because the table does not show it in every state.
applyColumnsInScope(preset, presetColumns) {
const displayedColumns = preset.displayedColumns ?? [];
// a displayed column takes the rank of the preset. A column that the preset
// hides has no rank and keeps the current order at the end of its block.
const rank = (columnConfig) => {
const index = displayedColumns.indexOf(columnConfig.field);
return index === -1 ? displayedColumns.length : index;
};
let columnsConfig = [];
let scopeBlock = [];
// the sort runs for each block of neighbouring scope columns. This way a scope
// column never moves over a column outside the scope.
const addScopeBlock = () => {
scopeBlock.sort((a, b) => rank(a) - rank(b));
columnsConfig = columnsConfig.concat(scopeBlock);
scopeBlock = [];
};
this.table.getColumnDefinitions().forEach((columnConfig) => {
if (!presetColumns.includes(columnConfig.field)) {
addScopeBlock();
columnsConfig.push(columnConfig);
return;
}
columnConfig.visible = displayedColumns.includes(
columnConfig.field,
);
scopeBlock.push(columnConfig);
});
addScopeBlock();
this.table.setColumns(columnsConfig);
}
}
+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');
// *** 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';
}
+2062 -60
View File
File diff suppressed because it is too large Load Diff
+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))
{