Merge branch 'feature-78052/tempus-tag-events' into feature-68298/Tempus_Grundstruktur

This commit is contained in:
Andreas Österreicher
2026-09-07 15:04:23 +02:00
31 changed files with 1872 additions and 935 deletions
+2
View File
@@ -140,6 +140,8 @@ $route['api/frontend/v1/treemenudata/lvverwaltung/stdsem/(:any)/emp/(:any)/stg/(
// load routes from extensions, also look for environment-specific configs
$subdirs = ['application/config/extensions', 'application/config/' . ENVIRONMENT . '/extensions'];
$route['api/frontend/v1/tempus/Tags/getTagsByCalendar/(:num)'] = 'api/frontend/v1/tempus/Tags/getTagsByCalendar/$1';
foreach($subdirs as $subdir)
{
if(is_dir($subdir))
+8 -1
View File
@@ -2,4 +2,11 @@
$config['send_update_mails'] = false;
$config['calendar_start'] = 7;
$config['calendar_end'] = 23;
$config['calendar_end'] = 23;
$config['tempus_tags'] = [
'prioone' => ['readonly' => false],
'priotwo' => ['readonly' => true],
'hinweis' => ['readonly' => false],
];
@@ -0,0 +1,53 @@
<?php
if (!defined('BASEPATH'))
exit('No direct script access allowed');
class Tags extends Tag_Controller
{
const BERECHTIGUNG_KURZBZ = ['admin:rw', 'assistenz:rw'];
public function __construct()
{
parent::__construct([
'getTag' => self::BERECHTIGUNG_KURZBZ,
'getTags' => self::BERECHTIGUNG_KURZBZ,
'getTagsByCalendar' => self::BERECHTIGUNG_KURZBZ,
'addTag' => self::BERECHTIGUNG_KURZBZ,
'updateTag' => self::BERECHTIGUNG_KURZBZ,
'doneTag' => self::BERECHTIGUNG_KURZBZ,
'deleteTag' => self::BERECHTIGUNG_KURZBZ
]);
$this->config->load('tempus');
}
public function getTag($readonly_tags = null)
{
parent::getTag($this->config->item('tempus_tags'));
}
public function getTags($tags = null)
{
parent::getTags($this->config->item('tempus_tags'));
}
public function getTagsByCalendar($eindeutige_kalender_gruppen_id = null)
{
parent::getTagsByAssignmentTypeValue('eindeutige_kalender_gruppen_id', $eindeutige_kalender_gruppen_id, $this->config->item('tempus_tags'));
}
public function addTag($withZuordnung = true, $updatable_tags = null)
{
parent::addTag(true, $this->config->item('tempus_tags'));
}
public function updateTag($updatable_tags = null)
{
parent::updateTag($this->config->item('tempus_tags'));
}
public function deleteTag($withZuordnung = true, $updatable_tags = null)
{
parent::deleteTag(true, $this->config->item('tempus_tags'));
}
public function doneTag($updatable_tags = null)
{
parent::doneTag($this->config->item('tempus_tags'));
}
}
+31 -3
View File
@@ -44,7 +44,6 @@ class Tag_Controller extends FHCAPI_Controller
{
$language = $this->_getLanguageIndex();
$index_bezeichnung_mehrsprachig = $language - 1;
$id = $this->input->get('id');
if (is_array($readonly_tags) && !isEmptyArray($readonly_tags))
@@ -123,7 +122,36 @@ class Tag_Controller extends FHCAPI_Controller
$notiztypen = $this->NotiztypModel->loadWhere(array('aktiv' => true));
$this->terminateWithSuccess(hasData($notiztypen) ? getData($notiztypen) : array());
}
public function getTagsByAssignmentTypeValue($zuordnung_typ, $zuordnung_id, $tags = null)
{
$language = $this->_getLanguageIndex();
$index_bezeichnung_mehrsprachig = $language - 1;
$this->NotizzuordnungModel->addSelect(
'tbl_notizzuordnung.notiz_id as notiz_id,
typ_kurzbz as tag_typ_kurzbz,
array_to_json(bezeichnung_mehrsprachig::varchar[])->>'. $index_bezeichnung_mehrsprachig. ' as bezeichnung,
style,
beschreibung,
tag,
tbl_notiz.erledigt as done
'
);
if (is_array($tags) && !isEmptyArray($tags))
{
$tags = $this->_filterTag($tags, false);
$this->NotizzuordnungModel->db->where_in('tbl_notiz_typ.typ_kurzbz', $tags);
}
$this->NotizzuordnungModel->addJoin('public.tbl_notiz', 'public.tbl_notizzuordnung.notiz_id = public.tbl_notiz.notiz_id');
$this->NotizzuordnungModel->addJoin('public.tbl_notiz_typ', 'public.tbl_notiz.typ = public.tbl_notiz_typ.typ_kurzbz');
$this->NotizzuordnungModel->addOrder('prioritaet');
$notiztypen = $this->NotizzuordnungModel->loadWhere(array('aktiv' => true, $zuordnung_typ => $zuordnung_id));
$this->terminateWithSuccess(hasData($notiztypen) ? getData($notiztypen) : array());
}
public function addTag($withZuordnung = true, $updatable_tags = null)
{
$postData = $this->getPostJson();
@@ -143,7 +171,7 @@ class Tag_Controller extends FHCAPI_Controller
if (!in_array($postData->tag_typ_kurzbz, $tags))
$this->terminateWithError($this->p->t('ui', 'keineBerechtigung'));
}
if ($withZuordnung)
{
$return = array();
@@ -152,7 +180,7 @@ class Tag_Controller extends FHCAPI_Controller
$this->terminateWithError('Error occurred', self::ERROR_TYPE_GENERAL);
$values = array_unique($postData->values);
foreach ($values as $value)
{
$insertResult = $this->addNotiz($postData);
+143 -62
View File
@@ -23,6 +23,7 @@ class KalenderLib
$this->_ci->load->model('ressource/Stunde_model', 'StundeModel');
$this->_ci->load->model('organisation/Studiensemester_model', 'StudiensemesterModel');
$this->_ci->load->model('system/Variable_model', 'VariableModel');
$this->_ci->load->model('system/Sprache_model', 'SpracheModel');
$this->_ci->load->model('organisation/Ferien_model', 'FerienModel');
@@ -35,69 +36,75 @@ class KalenderLib
private function _getBasePlan($start_date, $end_date)
{
$language = $this->getLanguageIndex();
$index_bezeichnung_mehrsprachig = $language - 1;
$end_date = date('Y-m-d', strtotime($end_date . ' +1 day'));
$this->_ci->KalenderModel->addSelect('tbl_kalender.kalender_id,
tbl_kalender.eindeutige_kalender_gruppen_id,
tbl_kalender.status_kurzbz,
tbl_kalender.typ,
tbl_kalender.von,
tbl_kalender.bis,
tbl_kalender_ort.location,
tbl_kalender_ort.ort_kurzbz,
tbl_lehreinheit.lehreinheit_id,
tbl_lehreinheit.lehrveranstaltung_id,
tbl_lehreinheit.lehrfach_id,
tbl_lehreinheit.lehrform_kurzbz,
tbl_lehrveranstaltung.oe_kurzbz,
lehrfach.kurzbz AS lehrfach_kurzbz,
lehrfach.bezeichnung AS lehrfach_bezeichnung,
lehrfach.farbe,
COALESCE(orginasator.uid, tbl_lehreinheitmitarbeiter.mitarbeiter_uid) as mitarbeiter_uid,
COALESCE(reservierung_person.vorname, tbl_person.vorname) as vorname,
COALESCE(reservierung_person.nachname, tbl_person.nachname) as nachname,
COALESCE(reservierung_ma.kurzbz, tbl_mitarbeiter.kurzbz) AS ma_kurzbz,
teilnehmergruppe.gruppe_kurzbz as teilnehmerg_grp,
COALESCE (
UPPER(tbl_studiengang.typ || tbl_studiengang.kurzbz) ||
COALESCE(verbandgruppe.semester::varchar, \'\') ||
COALESCE(verbandgruppe.verband::varchar, \'\') ||
COALESCE(verbandgruppe.gruppe, \'\'),
\'\') as verband_grp,
tbl_kalender_event.beschreibung,
tbl_kalender_event.titel,
teilmitarbeiter.kurzbz as teilnehmer_kurzbz,
teilperson.vorname as teilnehmer_vorname,
teilperson.nachname as teilnehmer_nachname,
teilbenutzer.uid as teilnehmer_uid,
CASE
WHEN tbl_lehreinheitgruppe.gruppe_kurzbz IS NULL THEN
COALESCE(
UPPER(le_studiengang.typ || le_studiengang.kurzbz) ||
COALESCE(tbl_lehreinheitgruppe.semester::varchar, \'\') ||
COALESCE(tbl_lehreinheitgruppe.verband::varchar, \'\') ||
COALESCE(tbl_lehreinheitgruppe.gruppe, \'\'),
\'\')
ELSE tbl_lehreinheitgruppe.gruppe_kurzbz
END AS lehreinheit_gruppe_bezeichnung,
tbl_lehreinheitgruppe.gruppe_kurzbz as le_gruppe_kurzbz,
tbl_lehreinheitgruppe.studiengang_kz as le_studiengang_kz,
tbl_lehreinheitgruppe.semester as le_semester,
tbl_lehreinheitgruppe.verband as le_verband,
tbl_lehreinheitgruppe.gruppe as le_gruppe,
le_gruppe.direktinskription as le_direktinskription,
CASE
WHEN vonstunde.stunde IS NOT NULL AND bisstunde.stunde IS NOT NULL
THEN bisstunde.stunde - vonstunde.stunde + 1
ELSE ROUND(
COALESCE(
(
SELECT SUM(EXTRACT(EPOCH FROM (LEAST(stunde.ende, tbl_kalender.bis::time) - GREATEST(stunde.beginn, tbl_kalender.von::time))) / 60) / 45
FROM lehre.tbl_stunde stunde
WHERE stunde.beginn < tbl_kalender.bis::time AND stunde.ende > tbl_kalender.von::time)
,0)
,2
) END AS verplante_stunden');
tbl_kalender.eindeutige_kalender_gruppen_id,
tbl_kalender.status_kurzbz,
tbl_kalender.typ,
tbl_kalender.von,
tbl_kalender.bis,
tbl_kalender_ort.location,
tbl_kalender_ort.ort_kurzbz,
tbl_lehreinheit.lehreinheit_id,
tbl_lehreinheit.lehrveranstaltung_id,
tbl_lehreinheit.lehrfach_id,
tbl_lehreinheit.lehrform_kurzbz,
tbl_lehrveranstaltung.oe_kurzbz,
lehrfach.kurzbz AS lehrfach_kurzbz,
lehrfach.bezeichnung AS lehrfach_bezeichnung,
lehrfach.farbe,
COALESCE(orginasator.uid, tbl_lehreinheitmitarbeiter.mitarbeiter_uid) as mitarbeiter_uid,
COALESCE(reservierung_person.vorname, tbl_person.vorname) as vorname,
COALESCE(reservierung_person.nachname, tbl_person.nachname) as nachname,
COALESCE(reservierung_ma.kurzbz, tbl_mitarbeiter.kurzbz) AS ma_kurzbz,
teilnehmergruppe.gruppe_kurzbz as teilnehmerg_grp,
COALESCE (
UPPER(tbl_studiengang.typ || tbl_studiengang.kurzbz) ||
COALESCE(verbandgruppe.semester::varchar, \'\') ||
COALESCE(verbandgruppe.verband::varchar, \'\') ||
COALESCE(verbandgruppe.gruppe, \'\'),
\'\') as verband_grp,
tbl_kalender_event.beschreibung,
tbl_kalender_event.titel,
teilmitarbeiter.kurzbz as teilnehmer_kurzbz,
teilperson.vorname as teilnehmer_vorname,
teilperson.nachname as teilnehmer_nachname,
teilbenutzer.uid as teilnehmer_uid,
CASE
WHEN tbl_lehreinheitgruppe.gruppe_kurzbz IS NULL THEN
COALESCE(
UPPER(le_studiengang.typ || le_studiengang.kurzbz) ||
COALESCE(tbl_lehreinheitgruppe.semester::varchar, \'\') ||
COALESCE(tbl_lehreinheitgruppe.verband::varchar, \'\') ||
COALESCE(tbl_lehreinheitgruppe.gruppe, \'\'),
\'\')
ELSE tbl_lehreinheitgruppe.gruppe_kurzbz
END AS lehreinheit_gruppe_bezeichnung,
tbl_lehreinheitgruppe.gruppe_kurzbz as le_gruppe_kurzbz,
tbl_lehreinheitgruppe.studiengang_kz as le_studiengang_kz,
tbl_lehreinheitgruppe.semester as le_semester,
tbl_lehreinheitgruppe.verband as le_verband,
tbl_lehreinheitgruppe.gruppe as le_gruppe,
le_gruppe.direktinskription as le_direktinskription,
tag_data_agg.tags as tags,
resource_data_agg.resources as resources,
CASE
WHEN vonstunde.stunde IS NOT NULL AND bisstunde.stunde IS NOT NULL
THEN bisstunde.stunde - vonstunde.stunde + 1
ELSE ROUND(
COALESCE(
(
SELECT SUM(EXTRACT(EPOCH FROM (LEAST(stunde.ende, tbl_kalender.bis::time) - GREATEST(stunde.beginn, tbl_kalender.von::time))) / 60) / 45
FROM lehre.tbl_stunde stunde
WHERE stunde.beginn < tbl_kalender.bis::time AND stunde.ende > tbl_kalender.von::time)
,0)
,2
) END AS verplante_stunden
');
$this->_ci->KalenderModel->addJoin('lehre.tbl_kalender_lehreinheit', 'tbl_kalender.kalender_id = tbl_kalender_lehreinheit.kalender_id', 'LEFT');
$this->_ci->KalenderModel->addJoin('lehre.tbl_kalender_event', 'tbl_kalender.kalender_id = tbl_kalender_event.kalender_id', 'LEFT');
@@ -143,6 +150,69 @@ class KalenderLib
);
$this->_ci->KalenderModel->addJoin('public.tbl_studiengang le_studiengang', 'le_lehrverband.studiengang_kz = le_studiengang.studiengang_kz', 'LEFT');
// Include tags in the query
$this->_ci->load->config('tempus');
$tags = $this->_ci->config->item('tempus_tags');
$whereTags = '';
if (is_array($tags) && !isEmptyArray($tags)) {
$tags = array_keys($tags);
foreach ($tags as $key => $tag) {
$tags[$key] = $this->_ci->KalenderModel->escape($tag);
}
$whereTags = " AND nt.typ_kurzbz IN (" . implode(",", $tags) . ")";
}
$subQueryTag = "(
SELECT
tag.eindeutige_kalender_gruppen_id,
COALESCE(json_agg(tag ORDER BY tag.done, tag.prioritaet), '[]'::json) AS tags
FROM (
SELECT DISTINCT ON (n.notiz_id)
n.notiz_id AS id,
nt.typ_kurzbz,
array_to_json(nt.bezeichnung_mehrsprachig::varchar[])->>". $index_bezeichnung_mehrsprachig. " as beschreibung,
n.text AS notiz,
nt.style,
n.erledigt AS done,
nt.prioritaet AS prioritaet,
nz.eindeutige_kalender_gruppen_id
FROM public.tbl_notizzuordnung AS nz
JOIN public.tbl_notiz AS n ON nz.notiz_id = n.notiz_id
JOIN public.tbl_notiz_typ AS nt ON n.typ = nt.typ_kurzbz " . $whereTags . "
) AS tag
GROUP BY tag.eindeutige_kalender_gruppen_id
) AS tag_data_agg";
$this->_ci->KalenderModel->addJoin($subQueryTag, 'tag_data_agg.eindeutige_kalender_gruppen_id = tbl_kalender.eindeutige_kalender_gruppen_id', 'LEFT');
// End of tags inclusion
// Include assigned operational resources in the query
$subQueryResource = "(
SELECT
resource.eindeutige_kalender_gruppen_id,
COALESCE(json_agg(resource ORDER BY resource.beschreibung), '[]'::json) AS resources
FROM (
SELECT
betriebsmittel_kalender.*,
betriebsmittel.beschreibung,
betriebsmittel.verplanen
FROM lehre.tbl_betriebsmittel_kalender AS betriebsmittel_kalender
JOIN wawi.tbl_betriebsmittel AS betriebsmittel
ON betriebsmittel.betriebsmittel_id = betriebsmittel_kalender.betriebsmittel_id
WHERE betriebsmittel.verplanen = TRUE
) AS resource
GROUP BY resource.eindeutige_kalender_gruppen_id
) AS resource_data_agg";
$this->_ci->KalenderModel->addJoin(
$subQueryResource,
'resource_data_agg.eindeutige_kalender_gruppen_id = tbl_kalender.eindeutige_kalender_gruppen_id',
'LEFT'
);
// End of operational resources inclusion
$this->_ci->KalenderModel->db->join('lehre.tbl_stunde vonstunde', 'tbl_kalender.von::time = vonstunde.beginn', 'LEFT', FALSE);
$this->_ci->KalenderModel->db->join('lehre.tbl_stunde bisstunde', 'tbl_kalender.bis::time = bisstunde.ende', 'LEFT', FALSE);
@@ -169,6 +239,8 @@ class KalenderLib
{
$von = new DateTime($row->von);
$bis = new DateTime($row->bis);
$resources = isset($row->resources) ? $row->resources : [];
$parsedResources = is_string($resources) ? json_decode($resources, true) : $resources;
$events[$id] = (object) [
'eindeutige_kalender_gruppen_id' => $row->eindeutige_kalender_gruppen_id,
@@ -199,7 +271,9 @@ class KalenderLib
'beschreibung' => isset($row->beschreibung) ? $row->beschreibung : '',
'topic' => [],
'collisions' => false,
'has_assigned_resources' => isset($row->has_assigned_resources) ? $row->has_assigned_resources : false,
'has_assigned_resources' => !empty($parsedResources),
'resources' => is_array($parsedResources) ? $parsedResources : [],
'tags' => isset($row->tags) ? $row->tags : [],
];
}
@@ -1912,4 +1986,11 @@ class KalenderLib
return $allowed;
}
}
private function getLanguageIndex()
{
$this->_ci->SpracheModel->addSelect('index');
$result = $this->_ci->SpracheModel->loadWhere(array('sprache' => getUserLanguage()));
return hasData($result) ? getData($result)[0]->index : 1;
}
}
+1
View File
@@ -10,6 +10,7 @@
'filtercomponent' => true,
'navigationcomponent' => true,
'vuedatepicker11' => true,
'tags' => true,
'phrases' => array(
'global',
'ui',
@@ -86,6 +86,9 @@
pointer-events: none;
background-color: transparent !important;
border: none !important;
width: fit-content;
margin-left: auto;
margin-right: auto;
}
.fhc-resize-bar--top {
+7
View File
@@ -17,6 +17,13 @@
transform: scale(1.15);
}
/* add prop that disasbles hover and cursor*/
.tag.disabled:hover
{
transform: none;
cursor: default;
}
.tag_rot {
background-color: #ff0000ff;
}
+59
View File
@@ -0,0 +1,59 @@
export default {
getTag(data)
{
return {
method: 'get',
url: '/api/frontend/v1/tempus/Tags/getTag',
params: data
};
},
getTags(data)
{
return {
method: 'get',
url: '/api/frontend/v1/tempus/Tags/getTags'
};
},
getTagsByCalendar(calenderGroupId)
{
return {
method: 'get',
url: '/api/frontend/v1/tempus/Tags/getTagsByCalendar/' + calenderGroupId,
};
},
addTag(data)
{
return {
method: 'post',
url: '/api/frontend/v1/tempus/Tags/addTag',
params: data
};
},
updateTag(data)
{
return {
method: 'post',
url: '/api/frontend/v1/tempus/Tags/updateTag',
params: data
};
},
doneTag(data)
{
return {
method: 'post',
url: '/api/frontend/v1/tempus/Tags/doneTag',
params: data
};
},
deleteTag(data)
{
return {
method: 'post',
url: '/api/frontend/v1/tempus/Tags/deleteTag',
params: data
};
},
};
+3 -2
View File
@@ -76,7 +76,8 @@ export default {
"drop",
"resize",
"event-hover",
"event-unhover"
"event-unhover",
"open-reservierung"
],
data() {
@@ -301,4 +302,4 @@ export default {
</div>
</template>
</fhc-calendar>`
}
}
+10 -6
View File
@@ -15,6 +15,10 @@ export default {
'deleted',
],
props: {
isListItemShown: {
type: Boolean,
default: true
},
endpoint: {
type: Object,
required: true
@@ -79,7 +83,7 @@ export default {
'id': tag_id
};
this.$api.call(this.endpoint.getTag(getData))
await this.$api.call(this.endpoint.getTag(getData))
.then(result => result.data)
.then(result => this.openModal(result))
},
@@ -132,7 +136,7 @@ export default {
{
postData.id = this.selectedTagId;
this.tagData.id = this.selectedTagId;
this.$api.call(this.endpoint.updateTag(postData));
await this.$api.call(this.endpoint.updateTag(postData));
this.$emit("updated", this.tagData);
this.$refs.tagModal.hide();
}
@@ -144,7 +148,7 @@ export default {
return;
}
this.$api.call(this.endpoint.addTag(postData))
await this.$api.call(this.endpoint.addTag(postData))
.then(response => response.data)
.then(response => {
if (typeof response === 'number') {
@@ -171,7 +175,7 @@ export default {
done: !this.tagData.done,
notiz: this.tagData.notiz,
}
this.$api.call(this.endpoint.doneTag(postData))
await this.$api.call(this.endpoint.doneTag(postData))
this.$emit("updated", this.tagData);
this.$refs.tagModal.hide();
},
@@ -180,7 +184,7 @@ export default {
let postData = {
id: this.selectedTagId
}
this.$api.call(this.endpoint.deleteTag(postData))
await this.$api.call(this.endpoint.deleteTag(postData))
this.$emit("deleted", this.selectedTagId)
this.$refs.tagModal.hide();
},
@@ -228,7 +232,7 @@ export default {
}
},
template: `
<div class="plus_button_container" @mouseleave="hideList">
<div v-if="isListItemShown" class="plus_button_container" @mouseleave="hideList">
<span :title="values.length === 0 ? 'Bitte Zeilen markieren' : ''">
<button @mouseover="showList = true"
:disabled="!values || values.length === 0"
+24
View File
@@ -0,0 +1,24 @@
import AppMenu from "../AppMenu.js";
export default {
name: "TempusAppMenu",
components: {
AppMenu,
},
template: /* html */ `
<aside id="appMenu" class="bg-light offcanvas offcanvas-start col-md p-md-0 h-100">
<div class="offcanvas-header">
Tempus
<button
type="button"
class="btn-close text-reset"
data-bs-dismiss="offcanvas"
:aria-label="$p.t('ui/schliessen')"
></button>
</div>
<div class="offcanvas-body">
<app-menu app-identifier="tempus" />
</div>
</aside>
`,
};
+108
View File
@@ -0,0 +1,108 @@
import CoreSearchbar from "../searchbar/searchbar.js";
import NavLanguage from "../navigation/Language.js";
export default {
name: "TempusHeader",
components: {
CoreSearchbar,
NavLanguage,
},
props: {
tempusRoot: String,
avatarUrl: String,
logoutUrl: String,
searchbaroptions: {
type: Object,
required: true,
},
searchfunction: {
type: Function,
required: true,
},
},
emits: ["language-changed"],
methods: {
focusSearchbar() {
this.$refs.searchbar?.$refs?.input?.focus();
},
},
template: /* html */ `
<header class="navbar navbar-expand-lg navbar-dark bg-dark flex-md-nowrap p-0 shadow">
<div class="col-md-4 col-lg-3 col-xl-2 d-flex align-items-center">
<button
class="btn btn-outline-light border-0 m-1 collapsed"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#appMenu"
aria-controls="appMenu"
aria-expanded="false"
:aria-label="$p.t('ui/toggle_nav')"
>
<span class="svg-icon svg-icon-apps"></span>
</button>
<a class="navbar-brand me-0" :href="tempusRoot">Tempus</a>
</div>
<button
class="btn btn-outline-light border-0 d-md-none m-1 collapsed"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#sidebarMenu"
aria-controls="sidebarMenu"
aria-expanded="false"
:aria-label="$p.t('ui/toggle_nav')"
>
<span class="fa-solid fa-table-list"></span>
</button>
<core-searchbar
ref="searchbar"
:searchoptions="searchbaroptions"
:searchfunction="searchfunction"
class="searchbar position-relative w-100"
show-btn-submit
></core-searchbar>
<div id="nav-user" class="dropdown">
<button
id="nav-user-btn"
class="btn btn-link rounded-0 py-0"
type="button"
data-bs-toggle="dropdown"
data-bs-target="#nav-user-menu"
aria-expanded="false"
aria-controls="nav-user-menu"
>
<img
:src="avatarUrl"
:alt="$p.t('profilUpdate/profilBild')"
class="bg-light avatar rounded-circle border border-light"
/>
</button>
<ul
ref="navUserDropdown"
class="dropdown-menu dropdown-menu-dark dropdown-menu-end rounded-0 text-center m-0"
aria-labelledby="nav-user-btn"
>
<li>
<button
type="button"
class="dropdown-item"
data-bs-toggle="modal"
data-bs-target="#configModal"
>
{{ $p.t('ui/settings') }}
</button>
</li>
<li><hr class="dropdown-divider m-0"/></li>
<li>
<nav-language @changed="$emit('language-changed', $event)" item-class="dropdown-item border-left-dark" />
</li>
<li><hr class="dropdown-divider m-0"/></li>
<li>
<a class="dropdown-item" :href="logoutUrl">
{{ $p.t('ui/logout') }}
</a>
</li>
</ul>
</div>
</header>
`,
};
@@ -0,0 +1,68 @@
/**
* Copyright (C) 2024 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 3 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, see <https://www.gnu.org/licenses/>.
*/
import BsModal from "../Bootstrap/Modal.js";
export default {
name: "HistoryModal",
components: {
BsModal,
},
props: {
entries: {
type: Array,
default: () => [],
},
},
methods: {
show() {
this.$refs.modal.show();
},
hide() {
this.$refs.modal.hide();
},
},
template: `
<bs-modal
ref="modal"
class="bootstrap-prompt"
dialog-class="modal-lg"
data-cy="historyModal"
>
<template #title>History</template>
<template #default>
<table v-if="entries.length" class="table table-bordered table-hover">
<thead class="table-light">
<tr>
<th>Von</th>
<th>Bis</th>
<th>Status</th>
<th>Ort</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in entries" :key="entry.id">
<td>{{ entry.von }}</td>
<td>{{ entry.bis }}</td>
<td>{{ entry.status_kurzbz }}</td>
<td>{{ entry.ort }}</td>
</tr>
</tbody>
</table>
</template>
</bs-modal>
`,
};
@@ -1,13 +1,19 @@
export default {
props:{
props: {
event: {
type: Object,
required: true
}
required: true,
},
},
computed:{
computed: {
classes() {
const classes = ['cis-renderer-lehreinheit-calendar-event', 'calendar-event-default', 'h-100', 'w-100', 'p-1'];
const classes = [
'cis-renderer-lehreinheit-calendar-event',
'calendar-event-default',
'h-100',
'w-100',
'p-1',
];
if (this.event.collisions) {
classes.push('calendar-event-collisions');
@@ -15,6 +21,23 @@ export default {
return classes;
},
topicString() {
return Array.isArray(this.event.topic)
? this.event.topic.join(', ')
: this.event.topic;
},
ortString() {
let orte = [
...(this.event.ort_kurzbz || []),
this.event.location?.trim(),
].filter(Boolean);
return orte.join(', ');
},
gruppeString() {
return Array.isArray(this.event.gruppe)
? this.event.gruppe.map((gruppe) => gruppe.bezeichnung).join(', ')
: this.event.gruppe;
},
statusIcon()
{
if (['planning', 'sync_preview'].includes(this.event.status_kurzbz))
@@ -24,80 +47,119 @@ export default {
else if (['live', 'to_delete_live'].includes(this.event.status_kurzbz))
return 'fa-solid fa-user-graduate text-muted'
},
topicString() {
return Array.isArray(this.event.topic) ? this.event.topic.join(', ') : this.event.topic;
},
ortString() {
let orte = [...(this.event.ort_kurzbz || []), this.event.location?.trim()].filter(Boolean)
return orte.join(', ')
},
gruppeString() {
return Array.isArray(this.event.gruppe)
? this.event.gruppe.map(gruppe => gruppe.bezeichnung).join(', ')
: this.event.gruppe;
},
tooltipString() {
const tooltipArray = [];
tooltipArray.push([
this.$p.t('global/uhrzeit'),
[this.start, this.end].join(' - ')
].join(": "));
tooltipArray.push(
[this.$p.t('global/uhrzeit'), [this.start, this.end].join(' - ')].join(
': ',
),
);
tooltipArray.push([
this.$p.t('profilUpdate/topic'),
this.topicString
].join(": "));
tooltipArray.push(
[this.$p.t('profilUpdate/topic'), this.topicString].join(': '),
);
tooltipArray.push([
this.$p.t('person/ort'),
this.ortString
].join(": "));
tooltipArray.push([this.$p.t('person/ort'), this.ortString].join(': '));
if (this.gruppeString) {
tooltipArray.push([
this.$p.t('lehre/gruppe'),
this.gruppeString
].join(": "));
tooltipArray.push(
[this.$p.t('lehre/gruppe'), this.gruppeString].join(': '),
);
}
if (Array.isArray(this.event.lektor) && this.event.lektor.length > 0) {
if (this.event.lektor.length > 3) {
tooltipArray.push([
this.$p.t('lehre/lektor'),
this.event.lektor.slice(0, 3).map(lektor => lektor.kurzbz).join("\n")
+ "\n" + this.$p.t('lehre/weitereLektoren', [this.event.lektor.length - 3])
].join(": "));
tooltipArray.push(
[
this.$p.t('lehre/lektor'),
this.event.lektor
.slice(0, 3)
.map((lektor) => lektor.kurzbz)
.join('\n') +
'\n' +
this.$p.t('lehre/weitereLektoren', [
this.event.lektor.length - 3,
]),
].join(': '),
);
} else {
tooltipArray.push([
this.$p.t('lehre/lektor'),
this.event.lektor.map(lektor => lektor.kurzbz).join("\n")
].join(": "));
tooltipArray.push(
[
this.$p.t('lehre/lektor'),
this.event.lektor.map((lektor) => lektor.kurzbz).join('\n'),
].join(': '),
);
}
}
return tooltipArray.join("\n");
return tooltipArray.join('\n');
},
start() {
return luxon.Duration
.fromISOTime(this.event.beginn)
.toISOTime({ suppressSeconds: true });
return luxon.Duration.fromISOTime(this.event.beginn).toISOTime({
suppressSeconds: true,
});
},
end() {
return luxon.Duration
.fromISOTime(this.event.ende)
.toISOTime({ suppressSeconds: true });
}
return luxon.Duration.fromISOTime(this.event.ende).toISOTime({
suppressSeconds: true,
});
},
tags() {
if (typeof this.event.tags === 'string') {
try {
return JSON.parse(this.event.tags);
} catch (e) {
console.error('Failed to parse tags:', e);
return [];
}
}
return this.event.tags || [];
},
tagsTooltip() {
if (this.tags.length === 0) {
return '';
}
return this.tags.map((tag) => tag.beschreibung).join('\n');
},
resourcesTooltip() {
if (!this.event.resources || this.event.resources.length === 0) {
return '';
}
let resources = this.event.resources;
if (typeof this.event.resources === 'string') {
try {
resources = JSON.parse(this.event.resources);
} catch (e) {
console.error('Failed to parse resources:', e);
return '';
}
}
return resources.map((resource) => resource.beschreibung).join('\n');
},
},
template: /*html*/`
template: /*html*/ `
<div
:class="classes"
class="position-relative"
@wheel.stop
>
<div class="position-absolute top-0 start-0 m-1">
<div class="position-absolute top-0 start-0 m-1 d-flex gap-1" >
<i :class="statusIcon"></i>
<i class="fa-solid fa-table-list text-muted" v-if="event.has_assigned_resources"></i>
<i
v-tooltip="resourcesTooltip"
v-if="event.has_assigned_resources"
class="fa-solid fa-table-list text-muted"
></i>
<i
v-tooltip="tagsTooltip"
v-if="tags?.length"
class="fa-solid fa-tags"
></i>
</div>
<div class="position-absolute bottom-0 start-0 m-1">
{{event.verplante_stunden}}
@@ -131,4 +193,4 @@ export default {
</div>
</div>
`,
}
};
@@ -53,11 +53,33 @@ export default {
ortString() {
return Array.isArray(this.event.ort_kurzbz) ? this.event.ort_kurzbz.join(', ') : this.event.ort_kurzbz;
},
tags() {
if (typeof this.event.tags === 'string') {
try {
return JSON.parse(this.event.tags);
} catch (e) {
console.error('Failed to parse tags:', e);
return [];
}
}
return this.event.tags || [];
},
resourcesValue() {
let resources = this.event.resources;
if (typeof this.event.resources === 'string') {
try {
resources = JSON.parse(this.event.resources);
} catch (e) {
console.error('Failed to parse resources:', e);
return null;
}
}
return resources.map(resource => resource.beschreibung).join(', ');
},
},
methods: {
mehtodNumberPadding: function (number) {
return numberPadding(number);
},
methodFormatDate: function (d) {
return formatDate(d);
},
@@ -78,7 +100,7 @@ export default {
});
}
},
template: `
template: /*html*/`
<div>
<h5>
{{$p.t('lvinfo','lehrveranstaltungsinformationen')}}
@@ -143,9 +165,36 @@ export default {
}}</th>
<td>{{event.organisationseinheit}}</td>
</tr>
<tr v-if="resourcesValue">
<th>{{
$p.t('ui','betriebsmittel')?
$p.t('ui','betriebsmittel')+':'
:''
}}</th>
<td>
{{ resourcesValue }}
</td>
</tr>
<tr v-if="tags.length">
<th>{{
$p.t('ui','tags')?
$p.t('ui','tags')+':'
:''
}}</th>
<td>
<div>
<span
v-for="tag in tags"
:key="tag.tag_typ_kurzbz"
:class="[tag.style, { tag_done: tag.done }]"
class="tag disabled"
>{{ tag.beschreibung }}</span>
</div>
</td>
</tr>
</tbody>
</table>
<lv-menu :containerStyles="['p-0']" :rowStyles="['m-0']" v-if="lvMenu.length" :menu="lvMenu" />
</div>`,
}
}
@@ -52,9 +52,6 @@ export default {
},
},
methods: {
mehtodNumberPadding: function (number) {
return numberPadding(number);
},
methodFormatDate: function (d) {
return formatDate(d);
},
@@ -133,4 +130,4 @@ export default {
</table>
</div>`,
}
}
@@ -0,0 +1,282 @@
/**
* Copyright (C) 2024 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 3 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, see <https://www.gnu.org/licenses/>.
*/
import BsModal from "../Bootstrap/Modal.js";
import FormInput from "../Form/Input.js";
import ApiOperationalResourceToCalender from "../../api/factory/operationalResourceToCalender.js";
export default {
name: "ResourcesAssignmentModal",
components: {
BsModal,
FormInput,
},
emits: ["saveFinished"],
data() {
return {
calendar: null,
availableResources: [],
filteredAvailableResources: [],
selectedAvailableResource: null,
assignedResources: [],
areFormButtonsDisplayed: false,
};
},
computed: {
dropdownParsedAvailableResources() {
return this.availableResources
.map((unit) => ({
label: unit.beschreibung,
value: unit.betriebsmittel_id,
data: unit,
}))
.sort((a, b) => a.label?.localeCompare(b.label));
},
},
watch: {
selectedAvailableResource(newValue) {
if (!newValue) return;
this.assignedResources.push({
betriebsmittel_kalender_id: null,
betriebsmittel_id: newValue.data.betriebsmittel_id,
beschreibung: newValue.data.beschreibung,
anmerkung: "",
isNoteTextareaShown: false,
});
this.areFormButtonsDisplayed = true;
},
},
methods: {
async open(calendar) {
if (!calendar?.kalender_id) return;
this.calendar = calendar;
this.availableResources =
await this.fetchSchedulableResourcesByCalender(calendar.kalender_id);
this.filteredAvailableResources = [
...this.dropdownParsedAvailableResources,
];
this.assignedResources =
await this.fetchAssignedResourcesByCalender(calendar.kalender_id);
this.show();
},
show() {
this.$refs.modal.show();
},
hide() {
this.$refs.modal.hide();
},
reset() {
this.calendar = null;
this.availableResources = [];
this.filteredAvailableResources = [];
this.selectedAvailableResource = null;
this.assignedResources = [];
this.areFormButtonsDisplayed = false;
},
async fetchAssignedResourcesByCalender(calenderId) {
const result = await this.$api.call(
ApiOperationalResourceToCalender.getAssignedResourcesByCalender(
calenderId,
),
);
if (result.meta.status === "success") {
return result.data
.filter((unit) => !!unit)
.map((unit) => ({
isNoteTextareaShown:
unit.anmerkung && unit.anmerkung.trim() !== "",
...unit,
}));
}
this.$fhcAlert.alertError(
this.$p.t("ui", "failed_assigned_resources_fetch_error_message"),
);
return [];
},
async fetchSchedulableResourcesByCalender(calendarId) {
const result = await this.$api.call(
ApiOperationalResourceToCalender.getSchedulableResourcesByCalendar(
calendarId,
),
);
if (result.meta.status === "success") return result.data;
this.$fhcAlert.alertError(
this.$p.t("ui", "failed_schedulable_resources_fetch_error_message"),
);
return [];
},
filterAvailableResources(event) {
const query = event.query.toLowerCase();
const unassignedResources = this.dropdownParsedAvailableResources.filter(
(unit) =>
!this.assignedResources.some(
(assigned) => assigned.betriebsmittel_id === unit.value,
),
);
this.filteredAvailableResources = query
? unassignedResources.filter((unit) =>
unit.label.toLowerCase().includes(query),
)
: unassignedResources;
},
toggleAssignedResourceNoteInput(resource) {
const assignedResource = this.assignedResources.find(
(assigned) => assigned.betriebsmittel_id === resource.betriebsmittel_id,
);
if (assignedResource) {
assignedResource.isNoteTextareaShown =
!assignedResource.isNoteTextareaShown;
}
this.areFormButtonsDisplayed = true;
},
removeAssignedResource(resource) {
this.assignedResources = this.assignedResources.filter(
(assigned) =>
assigned.betriebsmittel_id !== resource.betriebsmittel_id,
);
this.areFormButtonsDisplayed = true;
},
async refreshResourcesAssignmentModalData() {
if (!this.calendar?.kalender_id) return;
this.availableResources =
await this.fetchSchedulableResourcesByCalender(
this.calendar.kalender_id,
);
this.filteredAvailableResources = [
...this.dropdownParsedAvailableResources,
];
this.assignedResources = await this.fetchAssignedResourcesByCalender(
this.calendar.kalender_id,
);
this.selectedAvailableResource = null;
this.areFormButtonsDisplayed = false;
},
async saveAssignedResourcesToCalendarItem() {
if (!this.calendar?.kalender_id) return;
const result = await this.$api.call(
ApiOperationalResourceToCalender.storeResourcesToCalendarRelationship(
this.calendar.kalender_id,
this.assignedResources,
),
);
if (result.meta.status === "success") {
this.$fhcAlert.alertSuccess(
this.$p.t("ui", "assigned_resources_save_success_message"),
);
await this.refreshResourcesAssignmentModalData();
} else {
this.$fhcAlert.alertError(
this.$p.t("ui", "failed_assigned_resources_save_error_message"),
);
}
this.$emit("saveFinished");
this.hide();
},
},
template: `
<bs-modal
ref="modal"
@hide-bs-modal="reset"
class="bootstrap-prompt"
data-cy="resourcesAssignmentModal"
>
<template #title>{{ $p.t('ui', 'resource_assignment_modal_title') }}</template>
<template #default>
<div class="mb-5">
<form-input
v-if="availableResources.length"
@item-select="selectedAvailableResource = $event.value"
:label="$p.t('ui', 'available_resources_label')"
:suggestions="filteredAvailableResources"
:option-value="option => option.value"
:option-label="option => option.label"
@complete="filterAvailableResources"
dropdown
force-selection
type="autocomplete"
name="availableResources"
:close-on-select="false"
/>
</div>
<div>
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="mb-2 mx-auto text-bold fw-1">
{{ $p.t('ui', 'assigned_resources_subtitle') }}
</h6>
</div>
<div v-if="assignedResources.length" class="mb-4">
<div
v-for="resource in assignedResources"
:key="resource.betriebsmittel_id"
class="shadow-sm p-2 mb-2 bg-body rounded"
>
<div class="d-flex justify-content-between align-items-center mb-1">
<p class="m-0">{{ resource.beschreibung }}</p>
<div class="d-flex justify-content-between align-items-center gap-2">
<a href="#" @click.prevent="toggleAssignedResourceNoteInput(resource)" class="ms-auto">
<i class="fa fa-edit text-primary"></i>
</a>
<a href="#" @click.prevent="removeAssignedResource(resource)" class="ms-auto">
<i class="fa fa-trash text-danger"></i>
</a>
</div>
</div>
<form-input
v-if="resource.isNoteTextareaShown"
v-model="resource.anmerkung"
@input="areFormButtonsDisplayed = true"
:placeholder="$capitalize($p.t('global/anmerkung'))"
:rows="1"
class="flex-grow-1"
type="textarea"
name="anmerkung"
/>
</div>
</div>
<div v-else class="d-flex align-items-center justify-content-center mb-2">
<p class="text-muted mb-0">{{ $p.t('ui', 'no_assigned_resources') }}</p>
</div>
<div
v-if="areFormButtonsDisplayed"
class="d-flex justify-content-end gap-2"
>
<button type="button" class="btn btn-secondary" @click="refreshResourcesAssignmentModalData">
{{ $p.t('ui', 'abbrechen') }}
</button>
<button type="button" class="btn btn-primary" @click="saveAssignedResourcesToCalendarItem">
{{ $p.t('ui', 'speichern') }}
</button>
</div>
</div>
</template>
</bs-modal>
`,
};
+190
View File
@@ -0,0 +1,190 @@
import FhcCoursepicker from './Coursepicker.js';
import LectureSelection from './Filters/LectureSelection.js';
import VerbandSelection from './Filters/VerbandSelection.js';
import RoomSelection from './Filters/RoomSelection.js';
import ParkingSlot from './ParkingSlot.js';
import StvStudiensemester from '../Stv/Studentenverwaltung/Studiensemester.js';
export default {
name: 'TempusSidebarMenu',
components: {
FhcCoursepicker,
LectureSelection,
VerbandSelection,
RoomSelection,
ParkingSlot,
StvStudiensemester,
},
props: {
previewRole: {
type: String,
required: true,
},
rooms: {
type: Array,
required: true,
},
studiengaenge: {
type: Array,
required: true,
},
studiengaengeAll: {
type: Array,
default: () => [],
},
lecturers: {
type: Array,
required: true,
},
selectedStudiensemester: {
type: String,
required: true,
},
raumvorschlagPreview: {
type: Object,
default: null,
},
raumvorschlagLoading: {
type: Boolean,
default: false,
},
},
emits: [
'update:previewRole',
'update:rooms',
'update:studiengaenge',
'update:parkedKeys',
'update:selectedStudiensemester',
'remove-lecturer',
'select-lecturer',
'select-kw',
'sync',
'preview-raumvorschlag',
],
methods: {
reloadCoursepicker() {
return this.$refs.coursepicker?.reload();
},
isParked(id) {
return this.$refs.parking?.isParked(id) ?? false;
},
park(items) {
return this.$refs.parking?.park(null, items);
},
unpark(item) {
return this.$refs.parking?.unpark(item);
},
},
computed: {
courseLecturers() {
if (!this.lecturers.length) return [];
return this.lecturers
.filter((lecture) => lecture.showCoursePicker)
.map((lecture) => lecture.uid);
},
},
template: /* html */ `
<nav
id="sidebarMenu"
class="bg-light offcanvas offcanvas-start col-md p-md-0 h-100 w-100"
>
<div class="sidebar-icons d-flex flex-row align-items-start py-2 gap-1 ps-2">
<button
class="btn btn-outline-secondary"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#verbandMenu"
aria-controls="verbandMenu"
aria-expanded="false"
title="Verband"
>
<span class="fa-solid fa-university"></span>
</button>
<button
class="btn btn-outline-secondary"
type="button"
data-bs-toggle="offcanvas"
data-bs-target="#verbandMenu"
aria-controls="verbandMenu"
aria-expanded="false"
title="Verband"
>
<span class="fa-solid fa-door-open"></span>
</button>
</div>
<div class="px-2 py-1 w-100">
<div
class="d-flex gap-1 py-1"
data-cy="previewRoleOptionsHolder"
>
<button
class="btn btn-sm"
:class="previewRole === 'planer' ? 'btn-dark' : 'btn-outline-dark'"
@click="$emit('update:previewRole', 'planer')"
>
<i class="fa-solid fa-pen-ruler me-1"></i>Planer
</button>
<button
class="btn btn-sm"
:class="previewRole === 'lektor' ? 'btn-primary' : 'btn-outline-primary'"
@click="$emit('update:previewRole', 'lektor')"
>
<i class="fa-solid fa-chalkboard-user me-1"></i>Lektor
</button>
<button
class="btn btn-sm"
:class="previewRole === 'student' ? 'btn-success' : 'btn-outline-success'"
@click="$emit('update:previewRole', 'student')"
>
<i class="fa-solid fa-user-graduate me-1"></i>Student
</button>
<button
class="btn btn-sm btn-outline-danger"
@click="$emit('sync')"
>
<i class="fa-solid fa-rotate me-1"></i>Sync
</button>
</div>
</div>
<room-selection
v-if="rooms.length"
:rooms="rooms"
@update:rooms="$emit('update:rooms', $event)"
></room-selection>
<verband-selection
v-if="studiengaenge.length"
:studiengaenge="studiengaenge"
:studiengaenge-all="studiengaengeAll"
@update:studiengaenge="$emit('update:studiengaenge', $event)"
></verband-selection>
<lecture-selection
v-if="lecturers.length"
:lecturers="lecturers"
@remove="$emit('remove-lecturer', $event)"
></lecture-selection>
<div class="d-flex flex-column flex-grow-1" style="min-height: 0">
<parking-slot
ref="parking"
@update:parked-keys="$emit('update:parkedKeys', $event)"
@select-kw="$emit('select-kw', $event)"
></parking-slot>
<fhc-coursepicker
ref="coursepicker"
:studiengaenge="studiengaenge"
:studiensemester="selectedStudiensemester"
:lecturers="courseLecturers"
:preview-lehreinheit-id="raumvorschlagPreview?.lehreinheit_id ?? null"
:preview-loading="raumvorschlagLoading"
@preview-raumvorschlag="$emit('preview-raumvorschlag', $event)"
@select-lecturer="$emit('select-lecturer', $event)"
@select-kw="$emit('select-kw', $event)"
></fhc-coursepicker>
</div>
<stv-studiensemester
:studiensemester-kurzbz="selectedStudiensemester"
@update:studiensemester-kurzbz="$emit('update:selectedStudiensemester', $event)"
></stv-studiensemester>
</nav>
`,
};
@@ -0,0 +1,239 @@
/**
* Copyright (C) 2024 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 3 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, see <https://www.gnu.org/licenses/>.
*/
import BsModal from "../Bootstrap/Modal.js";
import FormInput from "../Form/Input.js";
import CoreTag from "../Tag/Tag.js";
import ApiTempusTag from "../../api/factory/tempus/tag.js";
export default {
name: "TagsAssignmentModal",
components: {
BsModal,
FormInput,
CoreTag,
},
emits: ["tagsChanged"],
data() {
return {
calendar: null,
availableTags: [],
filteredAvailableTags: [],
selectedAvailableTag: null,
assignedTags: [],
tagEndpoint: ApiTempusTag,
tagModalEscapeHandler: null,
};
},
computed: {
dropdownParsedAvailableTags() {
return this.availableTags.map((tag) => ({
label: tag.bezeichnung,
value: tag.tag_typ_kurzbz,
data: tag,
}));
},
},
methods: {
async open(calendar) {
if (!calendar?.kalender_id) return;
this.calendar = calendar;
this.availableTags = await this.fetchAvailableTags();
this.filteredAvailableTags = [...this.dropdownParsedAvailableTags];
this.assignedTags = await this.fetchAssignedTagsByCalender(
calendar.eindeutige_kalender_gruppen_id,
);
this.show();
},
show() {
this.$refs.modal.show();
},
hide() {
this.$refs.modal.hide();
},
reset() {
this.removeTagModalEscapeHandler();
this.calendar = null;
this.availableTags = [];
this.filteredAvailableTags = [];
this.selectedAvailableTag = null;
this.assignedTags = [];
},
prepareTagModal() {
const tagModal = this.$refs.tagComponent?.$refs.tagModal;
const tagModalElement = tagModal?.$el;
if (!tagModalElement) return;
tagModalElement.addEventListener(
"shown.bs.modal",
() => {
this.tagModalEscapeHandler = (event) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopImmediatePropagation();
tagModal.hide();
};
document.addEventListener("keydown", this.tagModalEscapeHandler, true);
},
{ once: true },
);
tagModalElement.addEventListener(
"hidden.bs.modal",
() => this.restoreAssignmentModalFocus(),
{ once: true },
);
},
removeTagModalEscapeHandler() {
if (!this.tagModalEscapeHandler) return;
document.removeEventListener(
"keydown",
this.tagModalEscapeHandler,
true,
);
this.tagModalEscapeHandler = null;
},
restoreAssignmentModalFocus() {
this.removeTagModalEscapeHandler();
this.$refs.modal?.$el?.focus();
},
async fetchAvailableTags() {
const result = await this.$api.call(ApiTempusTag.getTags());
if (result.meta.status === "success") return result.data;
this.$fhcAlert.alertError(
this.$p.t("ui", "failed_available_tags_fetch_error_message"),
);
return [];
},
async fetchAssignedTagsByCalender(calendarGroupId) {
const result = await this.$api.call(
ApiTempusTag.getTagsByCalendar(calendarGroupId),
);
if (result.meta.status === "success") {
return result.data.filter((tag) => !!tag);
}
this.$fhcAlert.alertError(
this.$p.t("ui", "failed_assigned_tags_fetch_error_message"),
);
return [];
},
filterAvailableTags(event) {
const query = event.query.toLowerCase();
const unassignedTags = this.dropdownParsedAvailableTags.filter(
(tag) =>
!this.assignedTags.some(
(assigned) => assigned.tag_id === tag.value,
),
);
this.filteredAvailableTags = query
? unassignedTags.filter((tag) =>
tag.label.toLowerCase().includes(query),
)
: unassignedTags;
},
selectTag(event) {
this.selectedAvailableTag = event.value;
this.prepareTagModal();
this.$refs.tagComponent?.openModal(event.value.data);
},
editTag(tag) {
this.prepareTagModal();
this.$refs.tagComponent?.editTag(tag.notiz_id);
},
async handleCalendarTagChange() {
this.removeTagModalEscapeHandler();
const calendarGroupId = this.calendar?.eindeutige_kalender_gruppen_id;
if (!calendarGroupId) return;
this.assignedTags =
await this.fetchAssignedTagsByCalender(calendarGroupId);
this.$emit("tagsChanged");
},
},
template: /* html */`
<bs-modal
ref="modal"
@hide-bs-modal="reset"
body-class="p-4"
class="bootstrap-prompt"
data-cy="tagsAssignmentModal"
>
<template #title>{{ $p.t('ui', 'tags_assignment_modal_title') }}</template>
<template #default>
<div class="mb-5">
<form-input
v-if="availableTags.length"
@item-select="selectTag"
:label="$capitalize($p.t('ui', 'tags'))"
:suggestions="filteredAvailableTags"
:option-value="option => option.value"
:option-label="option => option.label"
@complete="filterAvailableTags"
dropdown
force-selection
type="autocomplete"
name="availableTags"
:close-on-select="false"
>
<template #option="{ option }">
<span :class="['tag', option.data.style]">{{ option.label }}</span>
</template>
</form-input>
</div>
<div>
<div class="d-flex align-items-center justify-content-between mb-2">
<h6 class="mb-1 mx-auto text-bold fw-1">
{{ $p.t('ui', 'assigned_tags_subtitle') }}
</h6>
</div>
<div v-if="assignedTags.length">
<span
v-for="tag in assignedTags"
:key="tag.tag_typ_kurzbz"
:class="[tag.style, { tag_done: tag.done }]"
@click="editTag(tag)"
class="tag"
>{{ tag.bezeichnung }}</span>
</div>
<div v-else class="d-flex align-items-center justify-content-center mb-2">
<p class="text-muted mb-0">{{ $p.t('ui', 'no_assigned_tags') }}</p>
</div>
</div>
</template>
</bs-modal>
<core-tag
v-if="calendar?.eindeutige_kalender_gruppen_id"
ref="tagComponent"
:is-list-item-shown="false"
:endpoint="tagEndpoint"
:values="[calendar.eindeutige_kalender_gruppen_id]"
zuordnung_typ="eindeutige_kalender_gruppen_id"
@added="handleCalendarTagChange"
@deleted="handleCalendarTagChange"
@updated="handleCalendarTagChange"
/>
`,
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,25 @@
import BaseTreemenu from '../Base/Treemenu.js';
export default {
name: 'TempusVerbandMenu',
components: {
BaseTreemenu,
},
emits: ['select-verband-and-close'],
methods: {
onSelectVerbandAndClose(payload) {
this.$emit('select-verband-and-close', payload);
},
},
template: /* html */ `
<div id="verbandMenu" class="offcanvas offcanvas-start col-md p-md-0 h-100" tabindex="-1" data-cy="verbandMenu">
<div class="offcanvas-header justify-content-end px-1 d-md-none">
<h5 class="offcanvas-title" id="verbandMenuLabel">
<i class="fa-solid fa-university me-2"></i>Verband
</h5>
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" :aria-label="$p.t('ui/schliessen')"></button>
</div>
<base-treemenu config="tempus" @select-entry="onSelectVerbandAndClose" class="col" style="height:0%"></base-treemenu>
</div>
`,
};
@@ -18,6 +18,11 @@ export function useContextMenuActions(handlers)
icon: 'fa-solid fa-table-list',
action: handlers.openResourcesAssignmentModal
},
{
label: 'Tags',
icon: 'fa-solid fa-tags',
action: handlers.openTagsModal
},
{
label: 'Freischalten für Voransicht',
icon: 'fa-solid fa-chalkboard-user',
+2
View File
@@ -99,6 +99,8 @@ require_once('dbupdate_3.4/76150_perm_other_lv_plan.php');
require_once('dbupdate_3.4/68957_dashboard_bookmark_neue_Spalte_sort.php');
require_once('dbupdate_3.4/68530_Dashboard_Cleanup.php');
require_once('dbupdate_3.4/77375_ressourcen.php');
require_once('dbupdate_3.4/78052_tempus_tag_events.php');
require_once('dbupdate_3.4/78318_rename_column_of_eindeutige_gruppen_id_to_eindeutige_kalender_gruppen_id_in_tbl_kalender.php');
require_once('dbupdate_3.4/78318_rename_column_of_eindeutige_gruppen_id_to_eindeutige_kalender_gruppen_id_in_tbl_kalender.php');
require_once('dbupdate_3.4/75959_StudVw_Automatische_Tags.php');
require_once('dbupdate_3.4/76160_lvv_favorites.php');
+1 -1
View File
@@ -257,7 +257,7 @@ if(!$result = @$db->db_query("SELECT status_kurzbz_max FROM lehre.tbl_kalender L
UPDATE lehre.tbl_kalender_status
SET status_kurzbz = 'to_delete',
bezeichnung = 'to_delete',
bezeichnung = 'to_delete'
WHERE status_kurzbz = 'todelete';
UPDATE lehre.tbl_kalender_status SET sort = 9 WHERE status_kurzbz = 'deleted';
UPDATE lehre.tbl_kalender_status SET sort = 10 WHERE status_kurzbz = 'archived';
@@ -0,0 +1,19 @@
<?php
if (! defined('DB_NAME')) exit('No direct script access allowed');
$result = $db->db_query("SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'tbl_notizzuordnung'
AND column_name = 'eindeutige_kalender_gruppen_id'");
if ($db->db_num_rows($result) === 0)
{
$qry = "ALTER TABLE public.tbl_notizzuordnung
ADD COLUMN eindeutige_kalender_gruppen_id UUID NULL DEFAULT NULL";
if (!$db->db_query($qry))
echo '<strong>public.tbl_notizzuordnung: '.$db->db_last_error().'</strong><br>';
else
echo '<br>public.tbl_notizzuordnung: eindeutige_kalender_gruppen_id column added';
}
+80
View File
@@ -62398,6 +62398,86 @@ I have been informed that I am under no obligation to consent to the transmissio
)
)
),
array(
'app' => 'core',
'category' => 'ui',
'phrase' => 'tags_assignment_modal_title',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Tag-Zuweisung',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Tag Assignment',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'ui',
'phrase' => 'assigned_tags_subtitle',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Zugewiesene Tags',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Assigned Tags',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'ui',
'phrase' => 'no_assigned_tags',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Keine zugewiesenen Tags',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'No Assigned Tags',
'description' => '',
'insertvon' => 'system'
)
)
),
array(
'app' => 'core',
'category' => 'ui',
'phrase' => 'tags',
'insertvon' => 'system',
'phrases' => array(
array(
'sprache' => 'German',
'text' => 'Tags',
'description' => '',
'insertvon' => 'system'
),
array(
'sprache' => 'English',
'text' => 'Tags',
'description' => '',
'insertvon' => 'system'
)
)
),
// generic tabulator phrases END
array(
'app' => 'core',
+11
View File
@@ -177,6 +177,17 @@ VALUES
(51006, 31),
(51004, 32);
-- Calendar setup for tags tests
INSERT INTO lehre.tbl_kalender
(von, bis, typ, status_kurzbz, vorgaenger_kalender_id, eindeutige_kalender_gruppen_id, insertamum, insertvon, updateamum, updatevon)
VALUES
(date_trunc('week', CURRENT_DATE) + INTERVAL '6 day' + TIME '16:55:00', date_trunc('week', CURRENT_DATE) + INTERVAL '6 day' + TIME '17:40:00', 'lehreinheit', 'live', NULL, 'ec1bd001-facf-4b21-a671-bcb2d4f037fd', now(), 'demoadmin', NULL, 'demoadmin');
INSERT INTO lehre.tbl_kalender_lehreinheit
(lehreinheit_id, kalender_id)
VALUES
(51004, 33);
-- INSERT INTO public.tbl_variablenname
-- (name, defaultwert)
-- VALUES('ignore_resources_collisions', 'false');
+5
View File
@@ -0,0 +1,5 @@
INSERT INTO public.tbl_notiz_typ
(typ_kurzbz, bezeichnung_mehrsprachig, beschreibung, automatisiert, aktiv, zuordnung, tag, style, vorrueckung, prioritaet/*, taglib*/)
VALUES
('prioone', '{Prio 1,Prio 1}', '', false, true, NULL, false, 'tag_rot', false, 1/*, NULL*/),
('priotwo', '{Prio 2,Prio 2}', '', false, true, NULL, false, 'tag_gelb', false, 2/*, NULL*/);
@@ -71,6 +71,20 @@ context("Tempus smoke tests", () => {
tempusPage.getResourcesModal().should("be.visible");
});
it("shows tags modal when selecting Tags from event context menu", () => {
tempusPage.waitForCalendarToFinishLoading();
tempusPage
.getCalendarEventsByWeekdayAndStartTime("Sunday", "16:55:00")
.should("have.length.greaterThan", 0);
tempusPage.getCalendarEventsByWeekdayAndStartTime("Sunday", "16:55:00").first().rightclick();
tempusPage.getEventContextMenuOption("Tags").click({ force: true });
waitForOk("@fetchTags");
waitForOk("@fetchTagsByCalendar");
tempusPage.getTagsModal().should("be.visible");
});
it("shows history modal when selecting History from event context menu", () => {
tempusPage.waitForCalendarToFinishLoading();
tempusPage.getCalendarEvents().should("have.length.greaterThan", 0);
@@ -72,6 +72,14 @@ class TempusPage {
method: "GET",
url: "**/tempus/OperationalResourceToCalenderAPI/getAssignedResourcesByCalender/**",
}).as("fetchAssignedResources");
cy.intercept({
method: "GET",
url: "**/tempus/Tags/getTags**",
}).as("fetchTags");
cy.intercept({
method: "GET",
url: "**/tempus/Tags/getTagsByCalendar/**",
}).as("fetchTagsByCalendar");
cy.intercept({
method: "POST",
url: "**/tempus/coursepicker/getByStg**",
@@ -345,6 +353,8 @@ class TempusPage {
cy
.get("[data-cy='resourcesAssignmentModal']")
.closest(".bootstrap-modal.show");
getTagsModal = () =>
cy.get("[data-cy='tagsAssignmentModal']").closest(".bootstrap-modal.show")
getCalendarEventRoom = (id) =>
this.getCalendarEventById(id).find("[data-cy='calendar-event-room']");
getStundenrasterToggle = () =>