Merge branch 'master' into feature-40314/Electronic_Onboarding_Anbindung_IDA

This commit is contained in:
Alexei Karpenko
2025-09-18 17:39:31 +02:00
859 changed files with 98833 additions and 9210 deletions
+38
View File
@@ -0,0 +1,38 @@
export default {
name: "BaseModal",
props: [
"id",
"cModalClass", // optional: add custom classes to modal class
"cModalDialogClass" // optional: add custom classes to modal-dialog class
],
onMounted()
{
const modal = new bootstrap.Modal(this.$refs.modal, {})
modal.show();
},
template: `
<div class="modal fade"
ref="modal"
:class="this.cModalClass"
:id="this.id"
:aria-labelledby="this.id + '_label'" aria-hidden="true" tabindex="-1">
<div class="modal-dialog modal-dialog-scrollable" :class="this.cModalDialogClass">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" :id="this.id + '_label'">
<slot name="title"></slot>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<slot name="body"></slot>
</div>
<div class="modal-footer">
<slot name="footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Abbrechen</button><!-- default -->
</slot>
</div>
</div>
</div>
</div>`
}
+32
View File
@@ -0,0 +1,32 @@
export default {
name: "BaseOffcanvas",
props: [
"id",
"cOffcanvasClass", // optional: add custom classes to offcanvas class
"closeFunc"
],
computed: {
OffcanvasClass() {
return this.cOffcanvasClass || 'offcanvas-end'; // default: slide in from right to left
}
},
template: `
<div class="offcanvas"
:class="this.OffcanvasClass"
:id="this.id"
:aria-labelledby="this.id + '_label'"
tabindex="-1"
data-bs-backdrop="false">
<div class="offcanvas-header">
<h5 class="offcanvas-title" :id="this.id + '_label'">
<slot name="title"></slot>
</h5>
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" @click="closeFunc" aria-label="Close"></button>
</div>
<div class="offcanvas-body">
<div>
<slot name="body"></slot>
</div>
</div>
</div>`
}
@@ -29,30 +29,69 @@ export default {
uid: {
type: [Number, String],
required: true
}
},
/** List of types to allow for creation */
betriebsmittelTypes: {
type: Array,
default: null
},
/**
* If true: only show the types specified in 'betriebsmittelTypes'.
* If false: show all available types.
*/
filterByProvidedTypes: Boolean
},
data() {
return {
tabulatorOptions: {
ajaxURL: 'dummy',
ajaxRequestFunc: this.endpoint.getAllBetriebsmittel,
ajaxParams: () => {
return {
type: this.typeId,
id: this.id
};
},
ajaxRequestFunc: () => this.$api.call(
this.endpoint.getAllBetriebsmittel(this.typeId, this.id, (this.filterByProvidedTypes ? this.betriebsmittelTypes : null))
),
ajaxResponse: (url, params, response) => response.data,
columns: [
{title: "Nummer", field: "nummer", width: 150},
{title: "PersonId", field: "person_id", visible: false},
{title: "Typ", field: "betriebsmitteltyp", width: 125},
{title: "Anmerkung", field: "anmerkung", visible: false},
{title: "Retourdatum", field: "format_retour", width: 128},
{
title: "Retourdatum",
field: "retouram",
width: 128,
formatter: function (cell) {
const dateStr = cell.getValue();
if (!dateStr) return "";
const date = new Date(dateStr);
return date.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour12: false
});
}
},
{title: "Beschreibung", field: "beschreibung"},
{title: "UID", field: "uid", width: 87},
{title: "Kaution", field: "kaution", visible: false},
{title: "Ausgabedatum", field: "format_ausgabe", width: 144, visible: false},
{
title: "Ausgabedatum",
field: "ausgegebenam",
width: 144,
visible: false,
formatter: function (cell) {
const dateStr = cell.getValue();
if (!dateStr) return "";
const date = new Date(dateStr);
return date.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour12: false
});
}
},
{title: "Betriebsmittel ID", field: "betriebsmittel_id", visible: false},
{title: "Betriebsmittelperson ID", field: "betriebsmittelperson_id", visible: false},
{
@@ -66,7 +105,7 @@ export default {
let button = document.createElement('button');
button.className = 'btn btn-outline-secondary btn-action';
button.innerHTML = '<i class="fa fa-print"></i>';
button.title = bernahmebestätigung drucken';
button.title = this.$p.t('betriebsmittel', 'btn_printUebernahmebestaetigung');
let cellData = cell.getData();
button.addEventListener(
'click',
@@ -82,7 +121,7 @@ export default {
button = document.createElement('button');
button.className = 'btn btn-outline-secondary btn-action';
button.innerHTML = '<i class="fa fa-edit"></i>';
button.title = 'Betriebsmittel bearbeiten';
button.title = this.$p.t('betriebsmittel', 'btn_editBetriebsmittel');
button.addEventListener(
'click',
(event) =>
@@ -93,7 +132,7 @@ export default {
button = document.createElement('button');
button.className = 'btn btn-outline-secondary btn-action';
button.innerHTML = '<i class="fa fa-xmark"></i>';
button.title = 'Betriebsmittel löschen';
button.title = this.$p.t('betriebsmittel', 'btn_deleteBetriebsmittel');
button.addEventListener(
'click',
() =>
@@ -108,8 +147,6 @@ export default {
layout: 'fitColumns',
layoutColumnsOnNewData: false,
height: '550',
selectableRangeMode: 'click',
selectable: true,
persistenceID: 'core-betriebsmittel'
},
tabulatorEvents: [
@@ -117,26 +154,43 @@ export default {
event: 'tableBuilt',
handler: async() => {
await this.$p.loadCategory(['wawi', 'global', 'infocenter']);
await this.$p.loadCategory(['wawi', 'global', 'infocenter', 'betriebsmittel', 'person']);
let cm = this.$refs.table.tabulator.columnManager;
cm.getColumnByField('nummer').component.updateDefinition({
title: this.$p.t('wawi', 'nummer')
});
cm.getColumnByField('betriebsmitteltyp').component.updateDefinition({
title: this.$p.t('global', 'typ')
});
cm.getColumnByField('anmerkung').component.updateDefinition({
title: this.$p.t('global', 'anmerkung')
});
cm.getColumnByField('format_retour').component.updateDefinition({
cm.getColumnByField('retouram').component.updateDefinition({
title: this.$p.t('wawi', 'retourdatum')
});
cm.getColumnByField('beschreibung').component.updateDefinition({
title: this.$p.t('global', 'beschreibung')
});
cm.getColumnByField('kaution').component.updateDefinition({
title: this.$p.t('infocenter', 'kaution')
});
cm.getColumnByField('format_ausgabe').component.updateDefinition({
cm.getColumnByField('ausgegebenam').component.updateDefinition({
title: this.$p.t('wawi', 'ausgabedatum')
});
cm.getColumnByField('betriebsmittel_id').component.updateDefinition({
title: this.$p.t('ui', 'betriebsmittel_id')
});
cm.getColumnByField('betriebsmittelperson_id').component.updateDefinition({
title: this.$p.t('ui', 'betriebsmittelperson_id')
});
cm.getColumnByField('person_id').component.updateDefinition({
title: this.$p.t('person', 'person_id')
});
cm.getColumnByField('uid').component.updateDefinition({
title: this.$p.t('person', 'uid')
});
}
}
],
@@ -171,7 +225,9 @@ export default {
.then(result => result
? betriebsmittelperson_id
: Promise.reject({handled: true}))
.then(this.endpoint.deleteBetriebsmittel)
.then(betriebsmittelperson_id => this.$api.call(
this.endpoint.deleteBetriebsmittel(betriebsmittelperson_id))
)
.then(result => {
this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successDelete'));
window.scrollTo(0, 0);
@@ -184,8 +240,8 @@ export default {
this.formData.uid = this.uid;
if (this.formData.betriebsmitteltyp == 'Inventar')
this.formData.betriebsmittel_id = this.formData.inventarData?.betriebsmittel_id;
return this.endpoint
.addNewBetriebsmittel(this.$refs.betriebsmittelData, this.id, this.formData)
return this.$refs.betriebsmittelData
.call(this.endpoint.addNewBetriebsmittel(this.id, this.formData))
.then(response => {
this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successSave'));
this.$refs.betriebsmittelModal.hide();
@@ -198,8 +254,11 @@ export default {
updateBetriebsmittel(betriebsmittelperson_id) {
if (this.formData.betriebsmitteltyp == 'Inventar')
this.formData.betriebsmittel_id = this.formData.inventarData?.betriebsmittel_id;
return this.endpoint
.updateBetriebsmittel(this.$refs.betriebsmittelData, betriebsmittelperson_id, this.formData)
return this.$refs.betriebsmittelData
.call(this.endpoint.updateBetriebsmittel(
betriebsmittelperson_id,
this.formData
))
.then(response => {
this.$fhcAlert.alertSuccess(this.$p.t('ui', 'successSave'));
this.$refs.betriebsmittelModal.hide();
@@ -212,8 +271,8 @@ export default {
loadBetriebsmittel(betriebsmittelperson_id) {
this.resetModal();
this.statusNew = false;
return this.endpoint
.loadBetriebsmittel(betriebsmittelperson_id)
return this.$api
.call(this.endpoint.loadBetriebsmittel(betriebsmittelperson_id))
.then(result => {
this.formData = result.data;
})
@@ -221,8 +280,8 @@ export default {
},
searchInventar(event) {
const encodedQuery = encodeURIComponent(event.query);
return this.endpoint
.loadInventarliste(encodedQuery)
return this.$api
.call(this.endpoint.loadInventarliste(encodedQuery))
.then(result => {
this.filteredInventar = result.data;
});
@@ -244,8 +303,8 @@ export default {
}
},
created() {
return this.endpoint
.getTypenBetriebsmittel()
return this.$api
.call(this.endpoint.getTypenBetriebsmittel(this.betriebsmittelTypes))
.then(result => {
this.listBetriebsmitteltyp = result.data;
})
@@ -260,8 +319,9 @@ export default {
table-only
:side-menu="false"
reload
:reload-btn-infotext="this.$p.t('table', 'reload')"
new-btn-show
new-btn-label="Betriebsmittel"
:new-btn-label="this.$p.t('ui', 'betriebsmittel')"
@click:new="actionNewBetriebsmittel"
>
</core-filter-cmpt>
@@ -278,7 +338,7 @@ export default {
<div class="row mb-3">
<form-input
type="select"
:label="$p.t('global/typ')"
:label="$p.t('global/typ') + ' *'"
name="betriebsmitteltyp"
v-model="formData.betriebsmitteltyp"
:disabled="!statusNew"
@@ -376,6 +436,7 @@ export default {
v-model="formData.ausgegebenam"
auto-apply
:enable-time-picker="false"
text-input
format="dd.MM.yyyy"
preview-format="dd.MM.yyyy"
:teleport="true"
@@ -391,6 +452,7 @@ export default {
v-model="formData.retouram"
auto-apply
:enable-time-picker="false"
text-input
format="dd.MM.yyyy"
preview-format="dd.MM.yyyy"
:teleport="true"
+1
View File
@@ -1,6 +1,7 @@
import BsModal from './Modal.js';
export default {
name: 'BootstrapAlert',
components: {
BsModal
},
@@ -1,6 +1,7 @@
import BsAlert from './Alert.js';
export default {
name: 'BootstrapConfirm',
mixins: [
BsAlert
],
+59 -15
View File
@@ -1,8 +1,10 @@
import Phrasen from '../../plugin/Phrasen.js';
//import Phrasen from '../../plugin/Phrasen.js';
export default {
name: 'BootstrapModal',
data: () => ({
modal: null
modal: null,
fullscreen: false
}),
props: {
backdrop: {
@@ -21,7 +23,23 @@ export default {
default: true
},
noCloseBtn: Boolean,
dialogClass: [String,Array,Object]
dialogClass: [String,Array,Object],
headerClass: {
type: [String,Array,Object],
default: ''
},
bodyClass: {
type: [String,Array,Object],
default: 'px-4 py-5'
},
footerClass: {
type: [String,Array,Object],
default: ''
},
allowFullscreenExpand: {
type: Boolean,
default: false
}
},
emits: [
"hideBsModal",
@@ -45,17 +63,18 @@ export default {
},
toggle() {
return this.modal.toggle();
},
toggleFullscreen() {
this.fullscreen = !this.fullscreen
}
},
mounted() {
if(this.$refs.modal)
{
if (this.$refs.modal)
this.modal = new bootstrap.Modal(this.$refs.modal, {
backdrop: this.backdrop,
focus: this.focus,
keyboard: this.keyboard
});
}
},
popup(body, options, title, footer) {
const BsModal = this,
@@ -66,8 +85,16 @@ export default {
slots.title = () => title;
if (footer !== undefined)
slots.footer = () => footer;
// little hack to check whether primevue is included in the app or not
let includedPrimevue = false;
if(typeof primevue !== 'undefined'){
includedPrimevue = true;
}
return new Promise((resolve,reject) => {
const instance = Vue.createApp({
name: 'ModalTmpApp',
setup() {
return () => Vue.h(BsModal, {...{
class: 'fade'
@@ -78,6 +105,7 @@ export default {
},
mounted() {
this.$refs.modal.show();
},
beforeUnmount() {
if (this.$refs.modal)
@@ -88,22 +116,38 @@ export default {
}
});
const wrapper = document.createElement("div");
instance.use(Phrasen); // TODO(chris): find a more dynamic way
instance.mount(wrapper);
document.body.appendChild(wrapper);
// if(primevue) --> won't work because primevue is not defined in this scope and promise would be rejected
if (includedPrimevue){
instance.use(primevue.config.default, {zIndex: {overlay: 9999}})
}
//instance.use(Phrasen); // TODO(chris): find a more dynamic way
import('../../plugins/Phrasen.js').then((Phrasen) => {
instance.use(Phrasen.default);
instance.mount(wrapper);
document.body.appendChild(wrapper);
});
});
},
template: `<div ref="modal" class="bootstrap-modal modal" tabindex="-1" @[\`hide.bs.modal\`]="$emit('hideBsModal', $event)" @[\`hidden.bs.modal\`]="$emit('hiddenBsModal', $event)" @[\`hidePrevented.bs.modal\`]="$emit('hidePreventedBsModal', $event)" @[\`show.bs.modal\`]="$emit('showBsModal', $event)" @[\`shown.bs.modal\`]="$emit('shownBsModal', $event)">
<div class="modal-dialog" :class="dialogClass">
template: `<div ref="modal" class="bootstrap-modal modal" tabindex="-1" @[\`hide.bs.modal\`]="$emit('hideBsModal')" @[\`hidden.bs.modal\`]="$emit('hiddenBsModal')" @[\`hidePrevented.bs.modal\`]="$emit('hidePreventedBsModal')" @[\`show.bs.modal\`]="$emit('showBsModal')" @[\`shown.bs.modal\`]="$emit('shownBsModal')">
<div class="modal-dialog" :class="fullscreen ? 'modal-fullscreen' : dialogClass">
<div class="modal-content">
<div v-if="$slots.title" class="modal-header">
<div v-if="$slots.title" class="modal-header" :class="headerClass">
<h5 class="modal-title"><slot name="title"/></h5>
<button v-if="!noCloseBtn" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<div class="d-flex align-items-center ms-auto">
<button type="button" class="btn ms-auto" style="filter: invert(1)" v-if="allowFullscreenExpand" @click="toggleFullscreen">
<i v-if="!fullscreen" class="fa-solid fa-expand"></i>
<i v-else class="fa-solid fa-compress"></i>
</button>
<button v-if="!noCloseBtn" type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<slot name="modal-header-content"></slot>
</div>
<div class="modal-body">
<div class="modal-body" :class="bodyClass">
<slot></slot>
</div>
<div v-if="$slots.footer" class="modal-footer">
<div v-if="$slots.footer" class="modal-footer" :class="footerClass">
<slot name="footer"/>
</div>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
import BsAlert from './Alert.js';
export default {
name: 'BootstrapPrompt',
mixins: [
BsAlert
],
+311
View File
@@ -0,0 +1,311 @@
import BaseDraganddrop from './Base/DragAndDrop.js';
import BaseHeader from './Base/Header.js';
import BaseSlider from './Base/Slider.js';
import BsModal from '../Bootstrap/Modal.js';
import CalClick from '../../directives/Calendar/Click.js';
export default {
name: "CalendarBase",
components: {
BaseDraganddrop,
BaseHeader,
BaseSlider,
BsModal
},
directives: {
CalClick
},
provide() {
return {
locale: Vue.computed(() => this.locale),
timezone: Vue.computed(() => this.timezone),
timeGrid: Vue.computed(() => this.timeGrid),
draggableEvents: Vue.computed(() => {
if (!this.draggableEvents)
return () => false;
if (Array.isArray(this.draggableEvents))
return event => this.draggableEvents.includes(event.type);
if (this.draggableEvents instanceof Function)
return this.draggableEvents;
return () => true;
}),
dropableEvents: Vue.computed(() => {
if (!this.onDrop)
return () => false;
if (Array.isArray(this.dropableEvents))
return item => this.dropableEvents.includes(item.type);
if (this.dropableEvents instanceof Function)
return this.dropableEvents;
return () => true;
}),
hasDragoverFunc: Vue.computed(() => this.onDragover),
mode: Vue.computed(() => this.mode)
};
},
props: {
locale: {
type: String,
default: 'de'
},
timezone: {
type: String,
required: true
},
date: {
type: [Date, String, Number, luxon.DateTime],
default: props => luxon.DateTime.now().setZone(props.timezone).startOf('day')
},
modes: {
type: Object,
required: true,
default: {}
// TODO(chris): verfication functions
},
mode: String,
modeOptions: Object,
events: {
type: Array,
default: []
},
backgrounds: {
type: Array,
default: []
},
showBtns: Boolean,
btnMonth: {
type: Boolean,
default: undefined
},
btnWeek: {
type: Boolean,
default: undefined
},
btnDay: {
type: Boolean,
default: undefined
},
btnList: {
type: Boolean,
default: undefined
},
timeGrid: Array,
draggableEvents: [Boolean, Array, Function],
dropableEvents: [Boolean, Array, Function],
onDragover: Function,
onDrop: Function
},
emits: [
"click:next",
"click:prev",
"click:mode",
"click:event",
"click:day",
"click:week",
"update:date",
"update:mode",
"update:range",
"drop"
],
data() {
return {
internalView: null,
internalDate: null,
modalEvent: null
};
},
computed: {
convertedEvents() {
return this.events.map(orig => ({
id: orig.type + orig[orig.type + '_id'],
type: orig.type,
start: luxon.DateTime.fromISO(orig.isostart).setZone(this.timezone),
end: luxon.DateTime.fromISO(orig.isoend).setZone(this.timezone),
orig
}));
},
convertedBackgrounds() {
return this.backgrounds.map(bg => {
const res = { ...bg };
if (res.start) {
if (Number.isInteger(res.start))
res.start = luxon.DateTime.fromMillis(res.start, { zone: this.timezone, locale: this.locale });
else if (res.start instanceof Date)
res.start = luxon.DateTime.fromJSDate(res.start, { zone: this.timezone, locale: this.locale });
else if (typeof res.start ===
'string' || res.start instanceof String)
res.start = luxon.DateTime.fromISO(res.start, { zone: this.timezone, locale: this.locale });
}
if (res.end) {
if (Number.isInteger(res.end))
res.end = luxon.DateTime.fromMillis(res.end, { zone: this.timezone, locale: this.locale });
else if (res.end instanceof Date)
res.end = luxon.DateTime.fromJSDate(res.end, { zone: this.timezone, locale: this.locale });
else if (typeof res.end ===
'string' || res.end instanceof String)
res.end = luxon.DateTime.fromISO(res.end, { zone: this.timezone, locale: this.locale });
}
return res;
});
},
sDate() {
if (this.date instanceof luxon.DateTime)
return this.date;
return luxon.DateTime.fromJSDate(new Date(this.date)).setZone(this.timezone);
},
cDate: {
get() {
const date = this.internalDate ? this.internalDate : this.sDate;
return date.setLocale(this.locale);
},
set(value) {
this.internalDate = value;
this.$emit('update:date', value, this.cMode);
}
},
sMode() {
// choose default mode
let mode = this.mode;
if (mode)
mode = mode.toLowerCase();
if (!mode || !this.modes[mode])
mode = Object.keys(this.modes).find(Boolean); // start with first entry as active mode
return mode || '';
},
cMode: {
get() {
return this.internalView ? this.internalView : this.sMode;
},
set(value) {
this.internalView = value;
this.$emit('update:mode', value, this.cDate);
}
}
},
watch: {
sDate(n, o) {
if (this.sDate.isValid && !this.sDate.hasSame(this.internalDate, 'day'))
this.internalDate = this.sDate;
},
sMode() {
if (this.sMode)
this.internalView = this.sMode;
}
},
methods: {
clickPrev() {
const evt = new Event('click:prev', {cancelable: true});
this.$emit('click:prev', evt);
if (evt.defaultPrevented)
return;
// default: switch page
this.$refs.mode.prevPage();
},
clickNext() {
const evt = new Event('click:next', {cancelable: true});
this.$emit('click:next', evt);
if (evt.defaultPrevented)
return;
// default: switch page
this.$refs.mode.nextPage();
},
handleClickDefaults(evt) {
// TODO(chris): implement
switch (evt.detail.source) {
case 'day':
if (this.cMode != 'day' && this.modes['day']) {
evt.stopPropagation();
this.cDate = evt.detail.value;
this.cMode = 'day';
}
break;
case 'week':
if (this.cMode != 'week' && this.modes['week']) {
evt.stopPropagation();
this.cDate = luxon.DateTime.fromObject({
localWeekNumber: evt.detail.value.number,
localWeekYear: evt.detail.value.year
}, {
zone: this.cDate.zoneName,
locale: this.cDate.locale
});
this.cMode = 'week';
}
break;
}
},
onDropItem(evt, start, end) {
this.$emit('drop', evt, start, end);
},
showEventModal(eventObj) {
this.modalEvent = eventObj;
this.$refs.modal.show();
},
hideEventModal() {
if (this.modalEvent)
this.modalEvent.closeFn = undefined;
this.$refs.modal.hide();
this.modalEvent = null;
},
onModalHidden() {
if (this.modalEvent.closeFn)
this.modalEvent.closeFn();
}
},
beforeUnmount() {
this.hideEventModal();
},
template: /* html */`
<div class="fhc-calendar-base h-100">
<base-draganddrop
class="card h-100"
:events="convertedEvents"
:backgrounds="convertedBackgrounds"
@drop="onDropItem"
v-cal-click:container
@cal-click-default.capture="handleClickDefaults"
>
<base-header
class="card-header"
v-model:date="cDate"
v-model:mode="cMode"
@prev="clickPrev"
@next="clickNext"
@click:mode="$emit('click:mode', $event)"
:btn-day="!!modes['day'] && (btnDay || (showBtns && btnDay !== false))"
:btn-week="!!modes['week'] && (btnWeek || (showBtns && btnWeek !== false))"
:btn-month="!!modes['month'] && (btnMonth || (showBtns && btnMonth !== false))"
:btn-list="!!modes['list'] && (btnList || (showBtns && btnList !== false))"
:mode-options="modeOptions ? modeOptions[cMode] : undefined"
>
<slot name="actions" />
</base-header>
<component
:is="modes ? modes[cMode] : null || 'div'"
ref="mode"
v-model:current-date="cDate"
@update:range="$emit('update:range', $event)"
@request-modal-open="showEventModal"
@request-modal-close="hideEventModal"
v-bind="modeOptions ? modeOptions[cMode] : null || {}"
>
<template v-slot="slot"><slot v-bind="slot" /></template>
</component>
</base-draganddrop>
<bs-modal ref="modal" dialog-class="modal-lg" body-class="" @hidden-bs-modal="onModalHidden">
<template #title>
<slot v-if="modalEvent" v-bind="{mode: 'eventheader', event: modalEvent.event}" />
</template>
<template #default>
<slot v-if="modalEvent" v-bind="{mode: 'event', event: modalEvent.event}" />
</template>
</bs-modal>
</div>
`
}
@@ -0,0 +1,162 @@
import DragAndDrop from '../../../helpers/DragAndDrop.js';
import CalDnd from '../../../directives/Calendar/DragAndDrop.js';
/**
* TODO(chris): this needs serious rework!
*/
export default {
name: "CalendarDragAndDrop",
directives: {
CalDnd
},
provide() {
return {
events: Vue.computed(() => this.correctedEvents),
backgrounds: Vue.computed(() => this.backgrounds),
dropAllowed: Vue.computed(() => this.dragging && this.dropAllowed)
};
},
inject: {
mode: "mode",
dropableEvents: "dropableEvents"
},
props: {
events: Array,
backgrounds: Array
},
emits: [
"drop"
],
data() {
return {
dragging: false,
allowed: false,
draggedInternalEvent: null,
draggedExternalEvent: null,
targetTimestamp: 0,
targetGridEnds: null,
dropAllowed: false,
shadowPreview: false // TODO(chris): IMPLEMENT! (use background instead of event as preview)
};
},
computed: {
correctedEvents() {
if (this.dragging) {
if (this.draggedInternalEvent) {
const index = this.events.findIndex(e => e.id == this.draggedInternalEvent.id);
if (this.previewEvent && !this.shadowPreview)
return this.events.toSpliced(index, 1, this.previewEvent);
else
return this.events.toSpliced(index, 1);
}
if (this.previewEvent && !this.shadowPreview)
return [...this.events, this.previewEvent];
}
return this.events;
},
correctedBackgrounds() {
if (this.dragging) {
if (this.shadowPreview) {
// TODO(chris): how to get the length
return [...this.backgrounds, {
start: new Date(this.targetTimestamp),
class: 'shadow-preview'
}];
}
}
return this.backgrounds;
},
previewEvent() {
if (!this.dragging || !this.dropAllowed)
return null;
if (!this.targetTimestamp)
return null;
const event = this.draggedInternalEvent || this.draggedExternalEvent;
if (!event)
return null;
// TODO(chris): calculate length correctly from orig
let length = event.end - event.start;
if (this.targetGridEnds)
length = this.targetGridEnds.find(end => end >= this.targetTimestamp + length) - this.targetTimestamp;
return {
orig: event.orig,
start: this.targetTimestamp,
end: this.targetTimestamp + length
};
}
},
methods: {
onDragstart(evt) {
DragAndDrop.setTransferData(evt.detail.originalEvent, evt.detail.item.orig);
this.draggedInternalEvent = evt.detail.item;
},
onDragend() {
this.draggedInternalEvent = null;
this.dragging = false;
},
onDragenter(evt) {
this.dragging = true;
if (!this.draggedInternalEvent) {
const event = DragAndDrop.getValidTransferData(evt.detail.originalEvent);
if (event) {
this.draggedExternalEvent = {
id: event.id,
type: event.type,
start: event.isostart
? luxon.DateTime.fromISO(event.isostart).setZone(this.timezone)
: luxon.DateTime.local().setZone(this.timezone),
end: event.isoend
? luxon.DateTime.fromISO(event.isoend).setZone(this.timezone)
: luxon.DateTime.local().setZone(this.timezone),
orig: event
};
} else {
this.draggedExternalEvent = null;
}
this.dropAllowed = this.dropableEvents(event, this.mode);
} else {
this.dropAllowed = this.dropableEvents(this.draggedInternalEvent, this.mode);
}
},
onDragleave() {
this.dragging = false;
},
onDragchange(evt) {
this.targetTimestamp = evt.detail.timestamp;
this.targetGridEnds = evt.detail.ends || null;
},
onDrop(evt) {
if (!this.dragging || !this.dropAllowed)
return;
this.$emit('drop', evt, this.previewEvent.start, this.previewEvent.end);
this.dropAllowed = false;
this.dragging = false;
}
},
template: `
<div
class="fhc-calendar-base-draganddrop"
@calendar-dragstart="onDragstart"
@calendar-dragend="onDragend"
v-cal-dnd:dropcage
@calendar-dragenter="onDragenter"
@calendar-dragleave="onDragleave"
@calendar-dragchange="onDragchange"
@drop="onDrop"
>
<slot />
</div>
`
}
+435
View File
@@ -0,0 +1,435 @@
import GridLine from './Grid/Line.js';
import GridLineEvent from './Grid/Line/Event.js';
import CalDnd from '../../../directives/Calendar/DragAndDrop.js';
export default {
name: "CalendarGrid",
components: {
GridLine,
GridLineEvent
},
directives: {
CalDnd
},
inject: {
originalEvents: "events",
originalBackgrounds: "backgrounds",
dropAllowed: "dropAllowed"
},
provide() {
return {
flipAxis: Vue.computed(() => this.flipAxis),
axisRow: Vue.computed(() => this.axisRow)
};
},
props: {
axisMain: {
type: Array,
required: true,
validator(value) {
return value.every(item => item instanceof luxon.DateTime);
}
},
axisParts: {
type: Array,
required: true,
validator(value) {
return value.every(item =>
item instanceof luxon.Duration
|| Number.isInteger(item)
|| (
(
item.start instanceof luxon.Duration
|| Number.isInteger(item.start)
) && (
item.end instanceof luxon.Duration
|| Number.isInteger(item.end)
)
)
);
}
},
flipAxis: Boolean,
allDayEvents: Boolean,
axisMainCollapsible: Boolean,
snapToGrid: Boolean
},
data() {
return {
dragging: false,
resizeObserver: null,
mutationObserver: null,
userScroll: true
};
},
computed: {
axisRow() {
return this.flipAxis ? 'column' : 'row';
},
axisCol() {
return this.flipAxis ? 'row' : 'column';
},
axisPartsWithBreaks() {
return this.axisParts.reduce((res, tu, index) => {
const start = tu.start || tu;
const end = tu.end;
if (res.length) {
const lastTuEnd = res.pop();
if (Array.isArray(lastTuEnd)) {
res.push({
start: lastTuEnd[0],
end: start,
index: lastTuEnd[1]
});
} else if (lastTuEnd != start) {
// add pause
res.push({
start: lastTuEnd,
end: start
});
}
}
if (!end) {
res.push([start, index]);
} else {
res.push({
start,
end,
index
});
res.push(end);
}
return res;
}, []).slice(0, -1);
},
axisPartsSave() {
if (!this.axisParts[this.axisParts.length - 1].end)
return this.axisParts.slice(0, -1);
return this.axisParts;
},
start() {
return this.axisPartsWithBreaks[0].start;
},
end() {
return this.axisPartsWithBreaks[this.axisPartsWithBreaks.length - 1].end;
},
ends() {
const ends = [];
const partsEnds = this.axisPartsWithBreaks
.filter(p => p.index !== undefined)
.map(p => p.end);
for (var date of this.axisMain)
for (var part of partsEnds)
ends.push(date.plus(part));
return ends;
},
axisMainBorders() {
return this.axisMain.reduce(
(res, curr) => res.concat([curr.plus(this.start), curr.plus(this.end)]),
[]
);
},
eventsAllDay() {
if (!this.allDayEvents)
return [];
return this.mapIntoMainAxis(this.originalEvents.filter(event => event.orig.allDayEvent));
},
eventsNormal() {
if (!this.allDayEvents)
return this.events;
return this.mapIntoMainAxis(this.originalEvents.filter(event => !event.orig.allDayEvent));
},
events() {
return this.mapIntoMainAxis(this.originalEvents);
},
backgrounds() {
return this.mapIntoMainAxis(this.originalBackgrounds);
},
hasValidEvents() {
return this.events.find(e => e.length);
},
styleGridCols() {
let cols = 'repeat(' + this.axisMain.length + ', 1fr)';
if (this.axisMainCollapsible) {
if (this.hasValidEvents)
cols = this.events
.map(e => e.length
? '1fr'
: 'var(--fhc-calendar-axis-collapsible, .5fr)')
.join(' ');
}
return cols;
},
styleGridRows() {
const gridlines = {};
this.axisPartsWithBreaks.forEach(part => {
let ts = part.start.toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts];
if (part.index !== undefined)
gridlines[ts].push('ps_' + part.index);
ts = part.end.toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts];
if (part.index !== undefined)
gridlines[ts].push('pe_' + part.index);
});
this.eventsNormal.forEach((events, mainIndex) => {
let day = this.axisMain[mainIndex];
events.forEach(event => {
if (!event.startsHere && !event.endsHere)
return;
if (event.startsHere) {
let ts = event.start.diff(day).toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts, 'e_' + ts];
}
if (event.endsHere) {
let ts = event.end.diff(day).toMillis();
if (!gridlines[ts])
gridlines[ts] = ['t_' + ts, 'e_' + ts];
}
});
});
return Object.keys(gridlines).sort((a,b) => parseInt(a)-parseInt(b)).map((start, i, keys) => {
let end = keys[i + 1];
if (!end) {
gridlines[start].push('end');
return '[' + gridlines[start].join(' ') + ']';
}
return '[' + gridlines[start].join(' ') + '] ' + (end - start) + 'fr';
}).join(' ');
}
},
methods: {
mapIntoMainAxis(target) {
const result = Array.from({length: this.axisMain.length}, () => Array());
target.forEach(event => {
const start = event.start || this.axisMainBorders[0].plus(-1);
const end = event.end || this.axisMainBorders[this.axisMainBorders.length - 1].plus(1);
for (var i = 0; i < this.axisMain.length; i++) {
let laneStart = this.axisMainBorders[i * 2];
let laneEnd = this.axisMainBorders[i * 2 + 1];
if (event.orig?.allDayEvent) {
laneStart = laneStart.startOf('day');
laneEnd = laneEnd.endOf('day');
}
if (start < laneEnd && end > laneStart) {
const startsHere = start >= laneStart;
const endsHere = end <= laneEnd;
result[i].push({
...event,
startsHere,
endsHere
});
}
}
});
return result;
},
/* DRAG AND DROP */
getPageTop(el) {
let pageTop = el.offsetTop;
if (el.offsetParent)
pageTop += this.getPageTop(el.offsetParent);
return pageTop;
},
getPageLeft(el) {
let pageLeft = el.offsetLeft;
if (el.offsetParent)
pageLeft += this.getPageLeft(el.offsetParent);
return pageLeft;
},
getTimestampFromMouse(evt, dayTimestamp) {
let mouse, mouseFrac;
if (this.flipAxis) {
mouse = evt.pageX - this.getPageLeft(this.$refs.body) + this.$refs.main.scrollLeft;
mouseFrac = mouse / this.$refs.body.offsetWidth;
} else {
mouse = evt.pageY - this.getPageTop(this.$refs.body) + this.$refs.main.scrollTop;
mouseFrac = mouse / this.$refs.body.offsetHeight;
}
return dayTimestamp + this.start + Math.floor((this.end - this.start) * mouseFrac);
},
/* SCROLLING */
enableAutoScroll() {
if (!this.resizeObserver)
this.resizeObserver = new ResizeObserver(this.scrollToEarliestEvent);
this.resizeObserver.observe(this.$refs.body);
if (!this.mutationObserver)
this.mutationObserver = new MutationObserver(mutations => {
if (mutations.some(m => m.addedNodes.length && [].some.call(m.addedNodes, el => el.matches && el.matches('.fhc-calendar-base-grid-line-event'))))
this.scrollToEarliestEvent();
});
this.mutationObserver.observe(this.$refs.body, {
subtree: true,
childList: true
});
this.scrollToEarliestEvent();
},
disableAutoScroll() {
if (this.resizeObserver)
this.resizeObserver.disconnect();
this.resizeObserver = null;
if (this.mutationObserver)
this.mutationObserver.disconnect();
this.mutationObserver = null;
},
scrollToEarliestEvent() {
const eventElements = this.$refs.scroller.querySelectorAll('.fhc-calendar-base-grid-line-event');
let earliestEventOffset = [0, null];
for (var el of eventElements.values()) {
const top = el.offsetTop;
if (!earliestEventOffset[1] || top < earliestEventOffset[0])
earliestEventOffset = [top, el];
}
this.userScroll = false;
if (earliestEventOffset[1]) {
earliestEventOffset[1].scrollIntoView({ behavior: "smooth" });
} else {
this.$refs.scroller.scrollTo(0, 0);
}
}
},
beforeUnmount() {
this.disableAutoScroll();
},
template: /* html */`
<div
class="fhc-calendar-base-grid"
style="display:grid;width:100%;height:100%"
:style="'grid-template-' + axisRow + 's:auto' + (allDayEvents ? ' auto ' : ' ') + '1fr;grid-template-' + axisCol + 's:auto ' + styleGridCols"
>
<div
class="grid-header"
style="display:grid"
:style="'grid-template-' + axisCol + 's:subgrid;grid-' + axisCol + ':1/-1'"
>
<div
v-for="(date, index) in axisMain"
:key="index"
class="main-header"
:class="{'collapsed-header': axisMainCollapsible && hasValidEvents && !events[index].length}"
:style="'grid-' + axisCol + ':' + (2+index)"
>
<slot name="main-header" v-bind="{ index, date }" />
</div>
</div>
<div
v-if="allDayEvents"
class="grid-allday"
style="display:grid"
:style="'grid-template-' + axisCol + 's:subgrid;grid-' + axisCol + ':1/-1'"
>
<div
v-for="(events, index) in eventsAllDay"
:key="index"
class="all-day-events"
:style="'grid-' + axisCol + ':' + (2+index)"
>
<grid-line-event
v-for="(event, i) in events"
:key="i"
:event="event"
>
<template v-slot="slot">
<slot name="event" v-bind="slot" />
</template>
</grid-line-event>
</div>
</div>
<div
ref="scroller"
@scrollend="userScroll ? disableAutoScroll() : userScroll = true"
style="display:grid;overflow:auto"
:style="'grid-' + axisCol + ':1/-1;grid-template-' + axisCol + 's:subgrid'"
>
<div
ref="main"
class="grid-main"
style="position:relative;grid-column:1/-1;grid-row:1/-1;display:grid"
:style="'grid-template-' + axisCol + 's:subgrid;grid-template-' + axisRow + 's:' + styleGridRows"
>
<div
v-for="(part, index) in axisPartsSave"
:key="index"
class="part-header"
:style="'grid-' + axisCol + ':1;grid-' + axisRow + ': ps_' + index + '/pe_' + index"
>
<slot name="part-header" v-bind="{ index, part }" />
</div>
<div
ref="body"
class="grid-body"
style="display:grid;grid-template-rows:subgrid;grid-template-columns:subgrid"
:style="'grid-' + axisCol + ':2/-1;grid-' + axisRow + ':1/-1'"
v-cal-dnd:dropcage
@calendar-dragenter="dragging = true"
@calendar-dragleave="dragging = false"
@dragover="dropAllowed ? $event.preventDefault() : null"
>
<template
v-for="(date, index) in axisMain"
:key="index"
>
<div
v-for="(part, i) in axisPartsSave"
:key="i"
class="part-body"
style="position:relative"
:style="'grid-' + axisCol + ':' + (1+index) + ';grid-' + axisRow + ':ps_' + i + '/pe_' + i"
>
<slot name="part-body" v-bind="{ index, part }" />
<div
v-if="snapToGrid && dragging"
style="position:absolute;inset:0;z-index:1"
v-cal-dnd:dropzone.once="{date: date.plus(part.start || part), ends: ends.slice(ends.findIndex(end => end > date))}"
></div>
</div>
<grid-line
:start="date.plus(start)"
:end="date.plus(end)"
:date="date"
:events="eventsNormal[index]"
:backgrounds="backgrounds[index]"
style="position:relative"
:style="'grid-' + axisRow + ':1/-1;grid-' + axisCol + ':' + (1+index)"
>
<template #event="slot">
<slot name="event" v-bind="slot" />
</template>
<template #dropzone>
<div
v-if="!snapToGrid && dragging"
style="position:absolute;inset:0;z-index:1"
v-cal-dnd:dropzone="evt => getTimestampFromMouse(evt, date)"
></div>
</template>
</grid-line>
</template>
</div>
</div>
</div>
</div>
`
}
@@ -0,0 +1,85 @@
import LineEvent from './Line/Event.js';
import LineBackground from './Line/Background.js';
/**
* TODO(chris):
* Event overflow for Month mode (more-button)
*/
export default {
name: "GridLine",
components: {
LineEvent,
LineBackground
},
inject: {
axisRow: "axisRow"
},
props: {
date: {
type: luxon.DateTime,
required: true
},
start: {
type: luxon.DateTime,
required: true
},
end: {
type: luxon.DateTime,
required: true
},
events: {
type: Array,
default: []
},
backgrounds: {
type: Array,
default: []
}
},
computed: {
eventsWithRowInfo() {
const events = [];
this.events.forEach(event => {
const rows = [1, -1];
if (event.startsHere) {
rows[0] = 't_' + event.start.diff(this.date).toMillis();
}
if (event.endsHere) {
rows[1] = 't_' + event.end.diff(this.date).toMillis();
}
events.push({
...event,
rows
});
});
return events;
}
},
template: /* html */`
<div
class="fhc-calendar-base-grid-line"
style="position:relative;display:grid;grid-auto-flow:dense"
:style="'grid-template-' + axisRow + 's:subgrid'"
>
<line-background
v-for="bg in backgrounds"
:start="start"
:end="end"
:background="bg"
></line-background>
<line-event
v-for="(event, i) in eventsWithRowInfo"
:key="i"
:style="'grid-' + axisRow + ': ' + event.rows.join('/')"
:event="event"
>
<template v-slot="slot">
<slot name="event" v-bind="slot" />
</template>
</line-event>
<slot name="dropzone" />
</div>
`
}
@@ -0,0 +1,72 @@
export default {
name: "GridLineBackground",
inject: {
flipAxis: "flipAxis"
},
props: {
start: {
type: luxon.DateTime,
required: true
},
end: {
type: luxon.DateTime,
required: true
},
background: {
type: Object,
required: true,
validator(value) {
if (!value.start && !value.end)
return false;
if (value.start && !(value.start instanceof luxon.DateTime))
return false;
if (value.end && !(value.end instanceof luxon.DateTime))
return false;
return true;
}
}
},
computed: {
styles() {
if (!this.background.endsHere && !this.background.startsHere)
return this.background.style;
const perc = (this.end.ts - this.start.ts) / 100;
let border = {};
if (this.background.startsHere)
border[this.flipAxis ? 'left' : 'top'] = (this.background.start.diff(this.start)) / perc + '%';
if (this.background.endsHere)
border[this.flipAxis ? 'right' : 'bottom'] = (this.end.diff(this.background.end)) / perc + '%';
if (!this.background.style)
return border;
return [this.background.style, border];
},
classes() {
if (!this.background.endsHere && !this.background.startsHere)
return this.background.class;
const result = [];
if (this.background.class)
result.push(this.background.class);
if (this.background.startsHere)
result.push('bg-begin');
if (this.background.endsHere)
result.push('bg-end');
return result;
}
},
template: /* html */`
<div
class="fhc-calendar-base-grid-line-background"
:class="classes"
style="position:absolute;inset:0;z-index:0"
:style="styles"
:title="background.title"
>
<span v-if="background.label">{{ background.label }}</span>
</div>
`
}
@@ -0,0 +1,57 @@
import CalDnd from '../../../../../directives/Calendar/DragAndDrop.js';
import CalClick from '../../../../../directives/Calendar/Click.js';
export default {
name: "GridLineEvent",
directives: {
CalDnd,
CalClick
},
inject: {
draggableEvents: "draggableEvents",
mode: "mode"
},
props: {
event: {
type: Object,
required: true,
validator(value) {
return (value.start && value.end && value.orig);
}
}
},
computed: {
isHeaderOrFooter() {
return ['header', 'footer'].includes(this.event.orig);
},
draggable() {
return !this.isHeaderOrFooter && this.draggableEvents(this.event.orig, this.mode);
},
classes() {
const classes = [];
if (this.isHeaderOrFooter) {
classes.push('event-' + this.event.orig);
} else {
if (this.event.startsHere)
classes.push('event-begin');
if (this.event.endsHere)
classes.push('event-end');
}
return classes
}
},
template: /* html */`
<div
class="fhc-calendar-base-grid-line-event event"
:class="classes"
style="z-index: 1"
:draggable="draggable"
v-cal-dnd:draggable="event"
v-cal-click:event="isHeaderOrFooter ? event : event.orig"
>
<slot :event="isHeaderOrFooter ? event : event.orig">
{{ event.orig }}
</slot>
</div>
`
}
@@ -0,0 +1,123 @@
/**
* TODO(chris): use click-directive
*/
import DatePicker from './Header/Datepicker.js';
export default {
name: "CalendarHeader",
components: {
DatePicker
},
props: {
date: {
type: luxon.DateTime,
required: true
},
mode: {
type: String,
required: true
},
modeOptions: {
type: Object,
default: {}
},
btnMonth: Boolean,
btnWeek: Boolean,
btnDay: Boolean,
btnList: Boolean
},
emits: [
"next",
"prev",
"click:mode",
"update:date",
"update:mode"
],
data() {
return {
open: false
};
},
methods: {
clickMode(evt, mode) {
this.$emit('click:mode', evt);
if (!evt.defaultPrevented)
this.$emit('update:mode', mode);
}
},
template: /* html */`
<div class="fhc-calendar-base-header">
<div class="header-actions">
<div class="header-userdefined">
<slot />
</div>
<div class="header-modes">
<div class="d-flex gap-1 justify-content-end" role="group">
<button
v-if="btnMonth"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'month'}"
@click="clickMode($event, 'month')"
>
<i class="fa fa-calendar-days"></i>
</button>
<button
v-if="btnWeek"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'week'}"
@click="clickMode($event, 'week')"
>
<i class="fa fa-calendar-week"></i>
</button>
<button
v-if="btnDay"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'day'}"
@click="clickMode($event, 'day')"
>
<i class="fa fa-calendar-day"></i>
</button>
<button
v-if="btnList"
type="button"
class="btn btn-outline-secondary"
:class="{active: mode === 'list'}"
@click="clickMode($event, 'list')"
>
<i class="fa fa-table-list"></i>
</button>
</div>
</div>
</div>
<div class="header-picker">
<div class="btn-group" role="group">
<button
class="btn btn-outline-secondary border-0"
@click="$emit('prev')"
:disabled="open"
>
<i class="fa fa-chevron-left"></i>
</button>
<date-picker
:mode="mode"
:date="date"
@update:date="$emit('update:date', $event)"
@open="open = true"
@closed="open = false"
:list-length="modeOptions.length"
/>
<button
class="btn btn-outline-secondary border-0"
@click="$emit('next')"
:disabled="open"
>
<i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
</div>
`
}
@@ -0,0 +1,116 @@
// TODO(chris): translate aria-labels
export default {
name: "CalendarHeaderDatepicker",
components: {
VueDatePicker
},
inject: [
"locale",
"timezone"
],
props: {
date: {
type: luxon.DateTime,
required: true
},
mode: {
type: String,
required: true
},
listLength: {
type: Number,
default: 7
}
},
emits: [
"update:date"
],
computed: {
convertedDate() {
// convert to target TZ then strip TZ Information
// so the datepicker can work with local times
return this.date.setZone(this.timezone).setZone('local', { keepLocalTime: true });
},
current() {
switch (this.mode) {
case "month":
return {month: this.convertedDate.month-1, year: this.convertedDate.year};
case "list":
return [this.convertedDate.startOf('day').ts, this.convertedDate.startOf('day').plus({ days: this.listLength }).ts - 1];
case "week":
return [this.convertedDate.startOf('week', { useLocaleWeeks: true }).ts, this.convertedDate.endOf('week', { useLocaleWeeks: true }).ts];
case "day":
return this.convertedDate;
default:
return null;
}
},
title() {
switch (this.mode) {
case "month":
return this.date.toLocaleString({ month: 'long', year: 'numeric' });
case "week":
var year = this.date.localWeekYear;
var week = this.date.toFormat('nn');
return this.$p.t('calendar/year_kw', { year, week });
case "list":
return this.date.toLocaleString(luxon.DateTime.DATE_FULL) + '-' + this.date.plus({ days: this.listLength - 1 }).toLocaleString(luxon.DateTime.DATE_FULL);
case "day":
return this.date.toLocaleString(luxon.DateTime.DATE_FULL);
default:
return 'View not Supported';
}
},
weekStart() {
return luxon.Info.getStartOfWeek(this.date)%7;
}
},
methods: {
update(value) {
let date;
switch (this.mode) {
case "month":
value.month++;
date = luxon.DateTime.fromObject(value).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
break;
case "list":
case "week":
date = luxon.DateTime.fromJSDate(value[0]).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
break;
case "day":
date = luxon.DateTime.fromJSDate(value).setZone(this.timezone, { keepLocalTime: true }).setLocale(this.locale);
break;
default:
return; // Don't update if the value is invalid!
}
this.$emit("update:date", date);
},
weekNumbers(date) {
return luxon.DateTime.fromJSDate(date, { locale: this.locale }).localWeekNumber;
}
},
template: /* html */`
<vue-date-picker
:model-value="current"
@update:model-value="update"
:format="() => title"
:month-picker="mode == 'month'"
:week-picker="mode == 'week'"
:range="mode == 'list' ? { autoRange: listLength - 1 } : false"
:text-input="mode == 'day'"
:week-start="weekStart"
:week-numbers="{ type: weekNumbers }"
:clearable="false"
:enable-time-picker="false"
:config="{ keepActionRow: mode != 'month' }"
:action-row="{ showSelect: false, showCancel: false, showNow: mode != 'month', showPreview: false }"
auto-apply
six-weeks
teleport
:locale="locale"
:now-button-label="$p.t('calendar/today')"
:week-num-name="$p.t('calendar/kw')"
/>
`
}
@@ -0,0 +1,39 @@
import CalClick from '../../../../directives/Calendar/Click.js';
export default {
name: "LabelDay",
directives: {
CalClick
},
props: {
date: {
type: luxon.DateTime,
required: true
}
},
computed: {
titleFull() {
return this.date.toLocaleString({day: 'numeric', month: 'long', year: 'numeric'});
},
titleLong() {
return this.date.toLocaleString({day: '2-digit', month: '2-digit', year: 'numeric'});
},
titleShort() {
return this.date.toLocaleString({day: 'numeric', month: 'numeric'});
},
titleNarrow() {
return this.date.toLocaleString({day: 'numeric'});
}
},
template: /* html */`
<div
class="fhc-calendar-base-label-day"
v-cal-click:day="date"
>
<span class="full">{{ titleFull }}</span>
<span class="long">{{ titleLong }}</span>
<span class="short">{{ titleShort }}</span>
<span class="narrow">{{ titleNarrow }}</span>
</div>
`
}
@@ -0,0 +1,35 @@
import CalClick from '../../../../directives/Calendar/Click.js';
export default {
name: "LabelDow",
directives: {
CalClick
},
props: {
date: {
type: luxon.DateTime,
required: true
}
},
computed: {
titleLong() {
return this.date.toLocaleString({weekday: 'long'});
},
titleShort() {
return this.date.toLocaleString({weekday: 'short'});
},
titleNarrow() {
return this.date.toLocaleString({weekday: 'narrow'});
}
},
template: /* html */`
<div
class="fhc-calendar-base-label-dow"
v-cal-click:dow="date"
>
<b class="long">{{ titleLong }}</b>
<b class="short">{{ titleShort }}</b>
<b class="narrow">{{ titleNarrow }}</b>
</div>
`
}
@@ -0,0 +1,58 @@
export default {
name: "LabelTime",
props: {
part: {
type: [luxon.Duration, Number, Object],
required: true,
validator(value) {
if (value instanceof Object) {
if (value instanceof luxon.Duration)
return true;
let start_ok = true;
let end_ok = true;
if (value.start) {
start_ok = (
value.start instanceof luxon.Duration
|| Number.isInteger(value.start)
);
}
if (value.end) {
end_ok = (
value.end instanceof luxon.Duration
|| Number.isInteger(value.end)
);
}
return start_ok && end_ok;
}
return true;
}
}
},
computed: {
sanitizedTimestamps() {
return this.part.start || this.part.end ? this.part : { start: this.part };
},
start() {
if (!this.sanitizedTimestamps.start)
return null;
return this.formatTime(this.sanitizedTimestamps.start);
},
end() {
if (!this.sanitizedTimestamps.end)
return null;
return this.formatTime(this.sanitizedTimestamps.end);
}
},
methods: {
formatTime(date) {
return date.toISOTime({ suppressSeconds: true });
}
},
template: `
<div class="fhc-calendar-base-label-time">
<span v-if="start">{{ start }}</span>
<span v-if="end">-</span>
<span v-if="end">{{ end }}</span>
</div>
`
}
@@ -0,0 +1,38 @@
import CalClick from '../../../../directives/Calendar/Click.js';
export default {
name: "LabelWeek",
directives: {
CalClick
},
props: {
date: {
type: luxon.DateTime,
required: true
}
},
computed: {
weeks() {
const firstDay = this.date.startOf('week', { useLocaleWeeks: true });
const lastDay = this.date.endOf('week', { useLocaleWeeks: true });
const weeks = [
{ number: firstDay.localWeekNumber, year: firstDay.localWeekYear },
{ number: lastDay.localWeekNumber, year: lastDay.localWeekYear }
];
if (weeks[0].number == weeks[1].number)
weeks.pop();
return weeks;
}
},
template: `
<div class="fhc-calendar-base-label-week">
<span
v-for="week in weeks"
v-cal-click:week="week"
>
{{ week.number }}
</span>
</div>
`
}
@@ -0,0 +1,129 @@
export default {
name: 'CalendarSlider',
inject: {
time: {
from: "sliderTime",
default: ".3s"
}
},
emits: [
'slid'
],
data() {
return {
target: 0,
extrasAfter: 0,
extrasBefore: 0,
running: false,
promiseResolve: null
}
},
computed: {
itemsAfter() {
return [...Array(this.extrasAfter)].map((i, k) => 1+k);
},
itemsBefore() {
return [...Array(this.extrasBefore)].map((i, k) => k-this.extrasBefore);
},
styleSlider() {
const style = {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%'
};
if (this.running) {
style.left = (-this.target * 100) + '%';
style.transition = 'left ' + this.time + ' ease-in-out';
}
return style;
},
styleBefore() {
return {
position: 'absolute',
top: 0,
height: '100%',
display: 'flex',
right: '100%',
width: (this.extrasBefore * 100) + '%'
};
},
styleAfter() {
return {
position: 'absolute',
top: 0,
height: '100%',
display: 'flex',
left: '100%',
width: (this.extrasAfter * 100) + '%'
};
}
},
methods: {
prevPage() {
return this.slidePages(-1);
},
nextPage() {
return this.slidePages(1);
},
slidePages(dir) {
return new Promise(resolve => {
this.promiseResolve = resolve;
this.running = true;
const newTarget = this.target + dir;
if (newTarget > 0) {
if (this.extrasAfter < newTarget)
this.extrasAfter = newTarget;
} else if (newTarget < 0) {
if (-this.extrasBefore > newTarget)
this.extrasBefore = -newTarget;
}
this.target = newTarget;
});
},
endSlide() {
if (this.promiseResolve) {
this.promiseResolve(this.target);
this.promiseResolve = null;
}
this.$emit('slid', this.target);
this.running = false;
this.target = 0;
this.extrasAfter = this.extrasBefore = 0;
}
},
template: /* html */`
<div
class="fhc-calendar-base-slider h-100"
style="position:relative;overflow:hidden"
>
<div
:style="styleSlider"
@transitionend="endSlide"
>
<div :style="styleBefore">
<div
v-for="i in itemsBefore"
:key="i"
style="height:100%;width:100%"
>
<slot :offset="i" />
</div>
</div>
<div :style="styleAfter">
<div
v-for="i in itemsAfter"
:key="i"
style="height:100%;width:100%"
>
<slot :offset="i" />
</div>
</div>
<div style="height:100%;width:100%">
<slot :offset="0" />
</div>
</div>
</div>
`
}
+164
View File
@@ -0,0 +1,164 @@
import FhcCalendar from "./Base.js";
import ApiLvPlan from '../../api/factory/lvPlan.js';
import { useEventLoader } from '../../composables/EventLoader.js';
import ModeDay from './Mode/Day.js';
import ModeWeek from './Mode/Week.js';
import ModeMonth from './Mode/Month.js';
export default {
name: "CalendarLvPlan",
components: {
FhcCalendar
},
inject: [
"renderers"
],
props: {
timezone: {
type: String,
required: true
},
date: {
type: [Date, String, Number, luxon.DateTime],
default: luxon.DateTime.local()
},
mode: {
type: String,
default: 'Week'
},
getPromiseFunc: {
type: Function,
required: true
}
},
emits: [
"update:date",
"update:mode",
"update:range"
],
data() {
return {
modes: {
day: Vue.markRaw(ModeDay),
week: Vue.markRaw(ModeWeek),
month: Vue.markRaw(ModeMonth)
},
modeOptions: {
day: {
emptyMessage: Vue.computed(() => this.$p.t('lehre/noLvFound')),
emptyMessageDetails: Vue.computed(() => this.$p.t('lehre/noLvFound'))
},
week: {
collapseEmptyDays: false
}
},
teachingunits: null
};
},
computed: {
backgrounds() {
let now = luxon.DateTime.now().setZone(this.timezone);
if (this.mode == 'Month')
return [
{
class: 'background-past',
end: now.startOf('day')
}
];
return [
{
class: 'background-past',
end: now,
label: now.startOf('minute').toISOTime({ suppressSeconds: true, includeOffset: false })
}
];
}
},
methods: {
eventStyle(event) {
if (!event.farbe)
return undefined;
return '--event-bg:#' + event.farbe;
},
updateRange(rangeInterval) {
this.rangeInterval = rangeInterval;
this.$emit('update:range', rangeInterval);
}
},
setup(props, context) {
const rangeInterval = Vue.ref(null);
const { events, lv } = useEventLoader(rangeInterval, props.getPromiseFunc);
Vue.watch(lv, newValue => {
context.emit('update:lv', newValue);
});
return {
rangeInterval,
events,
lv
};
},
created() {
this.$api
.call(ApiLvPlan.getStunden())
.then(res => {
return this.teachingunits = res.data.map(el => ({
id: el.stunde,
start: el.beginn,
end: el.ende
}));
});
},
template: /* html */`
<fhc-calendar
ref="calendar"
class="fhc-calendar-lvplan"
:date="date"
:modes="modes"
:mode-options="modeOptions"
:mode="mode"
:timezone="timezone"
:locale="$p.user_locale.value"
:events="events || []"
:backgrounds="backgrounds"
:time-grid="teachingunits"
show-btns
@update:date="(newDate, newMode) => $emit('update:date', newDate, newMode)"
@update:mode="(newMode, newDate) => $emit('update:mode', newMode, newDate)"
@update:range="updateRange"
>
<template v-slot="{ event, mode }">
<div
:class="'event-type-' + event.type + ' ' + mode + 'PageContainer'"
:type="mode == 'day' ? 'button' : undefined"
:style="eventStyle(event)"
>
<component
v-if="mode == 'event'"
:is="renderers[event.type]?.modalContent"
:event="event"
></component>
<component
v-else-if="mode == 'eventheader'"
:is="renderers[event.type]?.modalTitle"
:event="event"
></component>
<component
v-else
:is="renderers[event.type]?.calendarEvent"
:event="event"
></component>
</div>
</template>
<template #actions>
<slot />
</template>
</fhc-calendar>`
}
+106
View File
@@ -0,0 +1,106 @@
import BaseSlider from '../Base/Slider.js';
import DayView from './Day/View.js';
export default {
name: "ModeDay",
components: {
BaseSlider,
DayView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
}
},
emits: [
"update:currentDate",
"update:range",
"click",
"requestModalOpen",
"requestModalClose"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate.startOf('day');
let last = this.focusDate.endOf('day');
if (this.rangeOffset != 0) {
if (this.rangeOffset < 0) {
first = first.plus({ days: this.rangeOffset });
} else {
last = last.plus({ days: this.rangeOffset });
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
if (this.currentDate.locale != this.focusDate.locale) {
this.focusDate = this.currentDate;
this.$emit('update:range', this.range);
} else {
this.rangeOffset = this.currentDate.startOf('day').diff(this.focusDate.startOf('day'), 'days').days;
if (this.rangeOffset) {
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(days) {
const newFocusDate = this.focusDate.plus({ days });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
this.$refs.view.$refs.grid.enableAutoScroll();
},
viewAttrs(days) {
const day = this.focusDate.plus({ days });
return { ...this.$attrs, day };
}
},
mounted() {
this.$emit('update:range', this.range);
this.$refs.view.$refs.grid.enableAutoScroll();
},
template: `
<div
class="fhc-calendar-mode-day flex-grow-1 position-relative"
>
<base-slider ref="slider" v-slot="slot">
<day-view
ref="view"
v-bind="viewAttrs(slot.offset)"
@request-modal-open="$emit('requestModalOpen', $event)"
@request-modal-close="$emit('requestModalClose', $event)"
>
<template v-slot="slot"><slot v-bind="slot" /></template>
</day-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,175 @@
import CalendarGrid from '../../Base/Grid.js';
import LabelDay from '../../Base/Label/Day.js';
import LabelDow from '../../Base/Label/Dow.js';
import LabelTime from '../../Base/Label/Time.js';
import { useResizeObserver } from '../../../../composables/Responsive.js';
export default {
name: "DayView",
components: {
CalendarGrid,
LabelDay,
LabelDow,
LabelTime
},
inject: {
timeGrid: "timeGrid",
originalEvents: "events",
timezone: "timezone"
},
props: {
day: {
type: luxon.DateTime,
required: true
},
emptyMessage: String,
emptyMessageDetails: String
},
emits: [
"requestModalOpen",
"requestModalClose"
],
data() {
return {
chosenEvent: null,
gridMainRef: null
};
},
computed: {
axisMain() {
return [this.day.startOf('day')];
},
axisParts() {
if (this.timeGrid) {
// create {start, end} array
return this.timeGrid.map(tu => {
return {
start: luxon.Duration.fromISOTime(tu.start),
end: luxon.Duration.fromISOTime(tu.end)
};
});
} else {
// create 07:00-23:00
return Array.from({ length: 17 }, (e, i) => luxon.Duration.fromObject({ hours: i + 7 }));
}
},
events() {
return this.originalEvents
.filter(event => event.start < this.day.plus({ days: 1 }) && event.end > this.day)
.sort((a, b) => a.start.ts - b.start.ts)
.map(evt => evt.orig);
},
currentEvent() {
if (this.chosenEvent) {
if (this.events.find(e => e == this.chosenEvent))
return this.chosenEvent;
}
let first = null;
if (this.events)
first = this.events.find(Boolean); // undefined => none found
if (first && first.type == 'loading')
return null; // null => loading
return first;
},
isToday() {
return this.day.hasSame(luxon.DateTime.now().setZone(this.timezone), 'day');
}
},
watch: {
compact() {
if (this.compact) {
if (this.chosenEvent) {
this.$emit('requestModalOpen', {
event: this.chosenEvent,
closeFn: () => { this.chosenEvent = null; }
});
}
} else {
this.$emit('requestModalClose');
}
}
},
methods: {
handleClickDefaults(evt) {
if (evt.detail.source == 'event') {
this.chosenEvent = evt.detail.value;
if (this.compact) {
this.$emit('requestModalOpen', {
event: this.chosenEvent,
closeFn: () => { this.chosenEvent = null; }
});
}
}
}
},
setup() {
const container = Vue.ref(null); // use useTemplateRef when updating to Vue 3.5
const { compact } = useResizeObserver(container, 750);
return {
container, // must be exposed or it won't be set in Vue < 3.5
compact
};
},
mounted() {
this.gridMainRef = this.$refs.grid.$refs.main;
},
template: /* html */`
<div
ref="container"
class="fhc-calendar-mode-day-view d-flex h-100"
@cal-click-default.capture="handleClickDefaults"
>
<calendar-grid
ref="grid"
:axis-main="axisMain"
:axis-parts="axisParts"
:snap-to-grid="!!timeGrid"
all-day-events
>
<template #main-header="{ date }">
<div :class="{ today: isToday }">
<label-dow
@cal-click="evt => evt.detail.source = 'day'"
v-bind="{ date }"
/>
<label-day
v-bind="{ date }"
/>
</div>
</template>
<template #part-header="{ part }">
<label-time v-bind="{ part }" />
</template>
<template #event="slot">
<div v-if="slot.event.type == 'loading'" class="placeholder-glow h-100 opacity-50">
<span class="placeholder w-100 h-100" />
</div>
<slot v-else v-bind="slot" mode="day" />
</template>
</calendar-grid>
<Teleport :disabled="!gridMainRef" :to="gridMainRef">
<div
v-if="emptyMessage && currentEvent !== null && !currentEvent"
class="fhc-calendar-no-events-overlay"
style="position:absolute;inset:0"
>
{{ emptyMessage }}
</div>
</Teleport>
<div class="event-details" v-if="!compact">
<div
v-if="currentEvent === null"
class="p-4 d-flex w-100 justify-content-center align-items-center"
>
<i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
<h3 v-else-if="!currentEvent">{{ emptyMessageDetails }}</h3>
<slot v-else :event="currentEvent" mode="event" />
</div>
</div>
`
}
+108
View File
@@ -0,0 +1,108 @@
import BaseSlider from '../Base/Slider.js';
import ListView from './List/View.js';
export default {
name: "ModeList",
components: {
BaseSlider,
ListView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
},
length: {
type: Number,
default: 7
}
},
emits: [
"update:currentDate",
"update:range",
"click",
"requestModalOpen"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate;
let last = first.plus({ days: this.length });
if (this.rangeOffset != 0) {
if (this.rangeOffset < 0) {
first = first.plus({ days: this.rangeOffset });
} else {
last = first.plus({ days: this.rangeOffset + this.length });
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
if (this.currentDate.locale != this.focusDate.locale) {
this.focusDate = this.currentDate;
this.$emit('update:range', this.range);
} else {
this.rangeOffset = this.currentDate.startOf('day').diff(this.focusDate.startOf('day'), 'days').days;
if (this.rangeOffset) {
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(offset) {
const newFocusDate = this.focusDate.plus({ days: offset });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
},
viewAttrs(offset) {
const day = this.focusDate.plus({ days: offset });
return { day, length: this.length };
},
handleClickDefaults(evt) {
switch (evt.detail.source) {
case 'event':
// default: Request Modal
this.$emit('requestModalOpen', { event: evt.detail.value });
break;
}
}
},
mounted() {
this.$emit('update:range', this.range);
},
template: `
<div
class="fhc-calendar-mode-list flex-grow-1 position-relative"
@cal-click-default.capture="handleClickDefaults"
>
<base-slider ref="slider" v-slot="slot">
<list-view v-bind="viewAttrs(slot.offset)">
<template v-slot="slot"><slot v-bind="slot" /></template>
</list-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,98 @@
import LabelDay from '../../Base/Label/Day.js';
import LabelDow from '../../Base/Label/Dow.js';
import CalDnd from '../../../../directives/Calendar/DragAndDrop.js';
import CalClick from '../../../../directives/Calendar/Click.js';
// TODO(chris): drag and drop
export default {
name: "ListView",
components: {
LabelDay,
LabelDow
},
directives: {
CalDnd,
CalClick
},
inject: {
draggableEvents: "draggableEvents",
events: "events",
timezone: "timezone"
},
props: {
day: {
type: luxon.DateTime,
required: true
},
length: {
type: Number,
required: true
}
},
data() {
return {
chosenEvent: null
};
},
computed: {
days() {
return Array.from({ length: this.length }, (e, days) => this.day.plus({ days }));
},
eventsPerDay() {
const eventsPerDay = this.days.map(day => {
return {
day,
events: this.events
.filter(event => event.start < day.plus({ days: 1 }) && event.end > day)
.sort((a, b) => a.start.ts - b.start.ts)
};
});
return eventsPerDay.filter(day => day.events.length);
}
},
methods: {
draggable(event) {
return this.draggableEvents(event.orig, 'list');
},
isToday(date) {
return date.hasSame(luxon.DateTime.now().setZone(this.timezone), 'day');
}
},
template: /* html */`
<div class="fhc-calendar-mode-list-view h-100 overflow-auto">
<div v-if="!eventsPerDay.length" class="h-100">
<slot :event="undefined" mode="list" />
</div>
<div v-for="{ day, events } in eventsPerDay">
<div
class="text-center"
:class="{ today: isToday(day) }"
>
<label-dow
:date="day"
class="d-inline"
@cal-click="evt => evt.detail.source = 'day'"
/>
,
<label-day :date="day" class="d-inline" />
</div>
<div v-for="event in events">
<div v-if="event.type == 'loading'" class="placeholder-glow opacity-50">
<span class="placeholder w-100" />
</div>
<div
v-else
class="event"
:draggable="draggable(event)"
v-cal-dnd:draggable="event"
v-cal-click:event="event.orig"
>
<slot :event="event.orig" mode="list" />
</div>
</div>
</div>
</div>
`
}
+126
View File
@@ -0,0 +1,126 @@
import BaseSlider from '../Base/Slider.js';
import MonthView from './Month/View.js';
export default {
name: "ModeMonth",
components: {
BaseSlider,
MonthView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
}
},
emits: [
"update:currentDate",
"update:range",
"click",
"requestModalOpen"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate.startOf('month').startOf('week', { useLocaleWeeks: true });
let last = first.plus({ days: 41 }).endOf('day'); // NOTE(chris): 6 weeks minus 1 day
if (this.rangeOffset != 0) {
const nextFocusDate = this.focusDate.plus({ months: this.rangeOffset});
const nextRangeStart = nextFocusDate.startOf('month').startOf('week', { useLocaleWeeks: true });
if (this.rangeOffset < 0) {
first = nextRangeStart;
} else {
last = nextRangeStart.plus({ days: 41 }).endOf('day');
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
if (this.currentDate.locale != this.focusDate.locale) {
this.focusDate = this.currentDate;
this.$emit('update:range', this.range);
} else {
this.rangeOffset = this.currentDate.startOf('month').diff(this.focusDate.startOf('month'), 'months').months;
if (this.rangeOffset) {
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(months) {
const newFocusDate = this.focusDate.plus({ months });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
},
viewAttrs(months) {
const day = this.focusDate.plus({ months });
return { day };
},
handleClickDefaults(evt) {
switch (evt.detail.source) {
case 'week':
// default: Move to week if not in month
let dayInWeek = luxon.DateTime.fromObject({
localWeekNumber: evt.detail.value.number,
localWeekYear: evt.detail.value.year
}, {
zone: this.currentDate.zoneName,
locale: this.currentDate.locale
});
if (!this.focusDate.hasSame(dayInWeek.startOf('week', { useLocaleWeeks: true }), 'month')) {
this.$emit('update:currentDate', dayInWeek.startOf('week', { useLocaleWeeks: true }));
} else if (!this.focusDate.hasSame(dayInWeek.endOf('week', { useLocaleWeeks: true }), 'month')) {
this.$emit('update:currentDate', dayInWeek.endOf('week', { useLocaleWeeks: true }));
}
break;
case 'day':
// default: Set current-date
this.$emit('update:currentDate', evt.detail.value);
break;
case 'event':
// default: Request Modal
this.$emit('requestModalOpen', { event: evt.detail.value });
break;
}
}
},
mounted() {
this.$emit('update:range', this.range);
},
template: `
<div
class="fhc-calendar-mode-month flex-grow-1 position-relative"
@cal-click-default.capture="handleClickDefaults"
>
<base-slider ref="slider" v-slot="slot">
<month-view v-bind="viewAttrs(slot.offset)">
<template v-slot="slot"><slot v-bind="slot" mode="month" /></template>
</month-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,95 @@
import CalendarGrid from '../../Base/Grid.js';
import LabelWeek from '../../Base/Label/Week.js';
import LabelDow from '../../Base/Label/Dow.js';
import LabelDay from '../../Base/Label/Day.js';
export default {
name: "MonthView",
components: {
CalendarGrid,
LabelWeek,
LabelDow,
LabelDay
},
provide() {
return {
// NOTE(chris): snap events to day
events: Vue.computed(() => {
//const events = [];
const events = this.events.map(event => {
const start = event.start.startOf('day');
const end = event.end.plus({ days: 1 }).startOf('day');
return {
...event,
start,
end
};
});
for (var w = 5; w > -1; w--) {
for (var d = 6; d > -1; d--) {
const startdate = this.axisMain[w].plus(this.axisParts[d]);
events.unshift({
start: startdate,
end: startdate.plus({ days: 1 }),
orig: 'header'
});
}
}
return events;
})
};
},
inject: {
events: "events",
timezone: "timezone"
},
props: {
day: {
type: luxon.DateTime,
required: true
}
},
computed: {
axisMain() {
const start = this.day.startOf('month').startOf('week', { useLocaleWeeks: true });
return Array.from({ length: 6 }, (e, i) => start.plus({ weeks: i }));
},
axisParts() {
return Array.from({ length: 8 }, (e, i) => luxon.Duration.fromObject({ days: i }));
}
},
methods: {
isToday(date) {
return date.hasSame(luxon.DateTime.now().setZone(this.timezone), 'day');
}
},
template: /* html */`
<div class="fhc-calendar-mode-month-view h-100">
<calendar-grid
flip-axis
:axis-main="axisMain"
:axis-parts="axisParts"
snap-to-grid
>
<template #main-header="{ date }">
<label-week v-bind="{ date }" />
</template>
<template #part-header="{ part }">
<label-dow :date="axisMain[0].plus(part)" class="text-center" />
</template>
<template #event="slot">
<label-day
v-if="slot.event.orig == 'header'"
:date="slot.event.start"
:class="{ disabled: !day.hasSame(slot.event.start, 'month'), today: isToday(slot.event.start) }"
/>
<div v-else-if="slot.event.type == 'loading'" class="placeholder-glow opacity-50">
<span class="placeholder w-100 fs-1" />
</div>
<slot v-else v-bind="slot" />
</template>
</calendar-grid>
</div>
`
}
+113
View File
@@ -0,0 +1,113 @@
import BaseSlider from '../Base/Slider.js';
import WeekView from './Week/View.js';
export default {
name: "ModeWeek",
components: {
BaseSlider,
WeekView
},
props: {
currentDate: {
type: luxon.DateTime,
required: true
}
},
emits: [
"update:currentDate",
"update:range",
"click",
"requestModalOpen"
],
data() {
return {
focusDate: this.currentDate,
rangeOffset: 0
};
},
computed: {
range() {
let first = this.focusDate.startOf('week', { useLocaleWeeks: true });
let last = this.focusDate.endOf('week', { useLocaleWeeks: true });
if (this.rangeOffset != 0) {
if (this.rangeOffset < 0) {
first = first.plus({ weeks: this.rangeOffset });
} else {
last = last.plus({ weeks: this.rangeOffset });
}
}
return luxon.Interval.fromDateTimes(first, last);
}
},
watch: {
currentDate() {
if (this.currentDate.locale != this.focusDate.locale) {
this.focusDate = this.currentDate;
this.$emit('update:range', this.range);
} else {
this.rangeOffset = this.currentDate.startOf('week', { useLocaleWeeks: true }).diff(this.focusDate.startOf('week', { useLocaleWeeks: true }), 'weeks').weeks;
if (this.rangeOffset) {
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.slidePages(this.rangeOffset).then(this.updatePage);
}
}
}
},
methods: {
prevPage() {
this.rangeOffset = this.$refs.slider.target - 1;
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.prevPage().then(this.updatePage);
},
nextPage() {
this.rangeOffset = this.$refs.slider.target + 1;
this.$refs.view.$refs.grid.disableAutoScroll();
this.$emit('update:range', this.range);
this.$refs.slider.nextPage().then(this.updatePage);
},
updatePage(weeks) {
const newFocusDate = this.focusDate.plus({ weeks });
this.focusDate = newFocusDate;
this.rangeOffset = 0;
this.$emit('update:currentDate', this.focusDate);
this.$emit('update:range', this.range);
this.$refs.view.$refs.grid.enableAutoScroll();
},
viewAttrs(weeks) {
const day = this.focusDate.plus({ weeks });
return { ...this.$attrs, day };
},
handleClickDefaults(evt) {
switch (evt.detail.source) {
case 'day':
// default: Set current-date
this.$emit('update:currentDate', evt.detail.value);
break;
case 'event':
// default: Request Modal
this.$emit('requestModalOpen', { event: evt.detail.value });
break;
}
}
},
mounted() {
this.$emit('update:range', this.range);
this.$refs.view.$refs.grid.enableAutoScroll();
},
template: `
<div
class="fhc-calendar-mode-week flex-grow-1 position-relative"
@cal-click-default.capture="handleClickDefaults"
>
<base-slider ref="slider" v-slot="slot">
<week-view ref="view" v-bind="viewAttrs(slot.offset)">
<template v-slot="slot"><slot v-bind="slot" mode="week" /></template>
</week-view>
</base-slider>
</div>
`
}
@@ -0,0 +1,85 @@
import CalendarGrid from '../../Base/Grid.js';
import LabelDay from '../../Base/Label/Day.js';
import LabelDow from '../../Base/Label/Dow.js';
import LabelTime from '../../Base/Label/Time.js';
export default {
name: "WeekView",
components: {
CalendarGrid,
LabelDay,
LabelDow,
LabelTime
},
inject: {
timeGrid: "timeGrid",
timezone: "timezone"
},
props: {
day: {
type: luxon.DateTime,
required: true
},
collapseEmptyDays: Boolean
},
computed: {
start() {
return this.day.startOf('week', { useLocaleWeeks: true });
},
axisMain() {
return Array.from({ length: 7 }, (e, i) => this.start.plus({ days: i }));
},
axisParts() {
if (this.timeGrid) {
// create {start, end} array
return this.timeGrid.map(tu => {
return {
start: luxon.Duration.fromISOTime(tu.start),
end: luxon.Duration.fromISOTime(tu.end)
};
});
} else {
// create 07:00-23:00
return Array.from({ length: 17 }, (e, i) => luxon.Duration.fromObject({ hours: i + 7 }));
}
}
},
methods: {
isToday(date) {
return date.hasSame(luxon.DateTime.now().setZone(this.timezone), 'day');
}
},
template: /* html */`
<div class="fhc-calendar-mode-week-view h-100">
<calendar-grid
ref="grid"
:axis-main="axisMain"
:axis-parts="axisParts"
:axis-main-collapsible="collapseEmptyDays"
:snap-to-grid="!!timeGrid"
all-day-events
>
<template #main-header="{ date }">
<div :class="{ today: isToday(date) }">
<label-dow
v-bind="{ date }"
@cal-click="evt => evt.detail.source = 'day'"
/>
<label-day
v-bind="{ date }"
/>
</div>
</template>
<template #part-header="{ part }">
<label-time v-bind="{ part }" />
</template>
<template #event="slot">
<div v-if="slot.event.type == 'loading'" class="placeholder-glow h-100 opacity-50">
<span class="placeholder w-100 h-100" />
</div>
<slot v-else v-bind="slot" />
</template>
</calendar-grid>
</div>
`
}
+106
View File
@@ -0,0 +1,106 @@
import FhcCalendar from "./Base.js";
import { useEventLoader } from '../../composables/EventLoader.js';
import ModeList from '../Calendar/Mode/List.js';
export default {
name: "CalendarWidget",
components: {
FhcCalendar
},
inject: [
"renderers"
],
props: {
timezone: {
type: String,
required: true
},
getPromiseFunc: {
type: Function,
required: true
}
},
data() {
return {
now: luxon.DateTime.now().setZone(this.timezone),
modes: {
list: Vue.markRaw(ModeList)
},
modeOptions: {
list: {
length: 7
}
}
};
},
methods: {
eventStyle(event) {
const styles = {};
if (event.farbe)
styles['--event-bg'] = '#' + event.farbe;
else if (event.type == 'reservierung')
styles['--event-bg'] = '#ffffff';
else
styles['--event-bg'] = '#cccccc';
const eventEnd = luxon.DateTime.fromISO(event.isoend, { zone: this.timezone });
if (eventEnd < this.now)
styles['opacity'] = .5;
return styles;
},
updateRange(rangeInterval) {
this.rangeInterval = rangeInterval;
}
},
setup(props) {
const rangeInterval = Vue.ref(null);
const { events } = useEventLoader(rangeInterval, props.getPromiseFunc);
return {
rangeInterval,
events
};
},
template: /* html */`
<fhc-calendar
:modes="modes"
:mode-options="modeOptions"
:timezone="timezone"
:locale="$p.user_locale.value"
:events="events || []"
@update:range="updateRange"
>
<template v-slot="{ event, mode }">
<div
v-if="!event"
class="h-100 d-flex justify-content-center align-items-center"
>
{{ $p.t('lehre/noLvFound') }}
</div>
<component
v-else-if="mode == 'eventheader'"
:is="renderers[event.type]?.modalTitle"
:event="event"
></component>
<component
v-else-if="mode == 'event'"
:is="renderers[event.type]?.modalContent"
:event="event"
></component>
<div
v-else
:class="'event-type-' + event.type + ' ' + mode + 'PageContainer'"
:style="eventStyle(event)"
>
<component
:is="renderers[event.type]?.calendarEvent"
:event="event"
></component>
</div>
</template>
</fhc-calendar>`
}
+24
View File
@@ -0,0 +1,24 @@
export const FhcChart = {
name: 'FhcChart',
props: {
chartOptions: {
type: Object,
}
},
template: `
<div style="width:100%;height:100%;overflow:auto">
<div role="group" aria-label="Chart Modus">
<Button :class="(chartOptions.chart.type === 'pie' ? 'active ' : '') + 'btn btn-outline-secondary'" style="width: 48px;" @click="chartOptions.chart.type='pie'"><i class="fa-solid fa-chart-pie"></i></Button>
<Button :class="(chartOptions.chart.type === 'bar' ? 'active ' : '') + 'btn btn-outline-secondary'" style="width: 48px;" @click="chartOptions.chart.type='bar'"><i class="fa-solid fa-chart-bar"></i></Button>
<Button :class="(chartOptions.chart.type === 'column' ? 'active ' : '') + 'btn btn-outline-secondary'" style="width: 48px;" @click="chartOptions.chart.type='column'"><i class="fa-solid fa-chart-simple"></i></Button>
<Button :class="(chartOptions.chart.type === 'line' ? 'active ' : '') + 'btn btn-outline-secondary'" style="width: 48px;" @click="chartOptions.chart.type='line'"><i class="fa-solid fa-chart-line"></i></Button>
</div>
<figure>
<highcharts class="chart" :options="chartOptions"></highcharts>
</figure>
</div>
`
};
export default FhcChart
@@ -0,0 +1,315 @@
import BsModal from '../../Bootstrap/Modal.js';
import VueDatePicker from '../../vueDatepicker.js.php';
const today = new Date()
export const AbgabeMitarbeiterDetail = {
name: "AbgabeMitarbeiterDetail",
components: {
BsModal,
InputNumber: primevue.inputnumber,
Checkbox: primevue.checkbox,
Dropdown: primevue.dropdown,
Textarea: primevue.textarea,
VueDatePicker
},
props: {
projektarbeit: {
type: Object,
default: null
}
},
data() {
return {
oldPaBeurteilungLink: 'https://moodle.technikum-wien.at/mod/page/view.php?id=1005052', // TODO: inject from app & app provide link from config
eidAkzeptiert: false,
enduploadTermin: null,
allActiveLanguages: FHC_JS_DATA_STORAGE_OBJECT.server_languages,
// TODO: fetch types
allAbgabeTypes: [
{
paabgabetyp_kurzbz: 'abstract',
bezeichnung: 'Entwurf'
},
{
paabgabetyp_kurzbz: 'zwischen',
bezeichnung: 'Zwischenabgabe'
},
{
paabgabetyp_kurzbz: 'note',
bezeichnung: 'Benotung'
},
{
paabgabetyp_kurzbz: 'end',
bezeichnung: 'Endupload'
},
{
paabgabetyp_kurzbz: 'enda',
bezeichnung: 'Endabgabe im Sekretariat'
}
]
}
},
methods: {
openZusatzdatenModal(termin) {
},
saveTermin(termin) {
const paabgabe_id = termin.paabgabe_id
this.$fhcApi.factory.lehre.postProjektarbeitAbgabe(termin).then( (res) => {
if(res?.meta?.status == 'success') {
this.$fhcAlert.alertSuccess(this.$p.t('ui/gespeichert'))
if(paabgabe_id === -1) { // new abgabe has been inserted
termin.paabgabe_id = res?.data?.retval
this.projektarbeit.abgabetermine.push({ // new abgatermin row
'paabgabe_id': -1,
'projektarbeit_id': this.projektarbeit.projektarbeit_id,
'fixtermin': false,
'kurzbz': '',
'datum': new Date().toISOString().split('T')[0],
'paabgabetyp_kurzbz': '',
'bezeichnung': '',
'abgabedatum': null,
'insertvon': this.viewData?.uid ?? '',
'allowedToSave': true,
'allowedToDelete': true
})
}
} else if(res?.meta?.status == 'error'){
this.$fhcAlert.alertError()
}
})
},
deleteTermin(termin) {
this.$fhcApi.factory.lehre.deleteProjektarbeitAbgabe(termin.paabgabe_id).then( (res) => {
if(res?.meta?.status == 'success') {
this.$fhcAlert.alertSuccess(this.$p.t('ui/genericDeleted', [this.$p.t('abgabetool/abgabe')]))
// this.$p.t('global/tooltipLektorDeleteKontrolle', [this.$entryParams.permissions.kontrolleDeleteMaxReach ])
const deletedTerminIndex = this.projektarbeit.abgabetermine.findIndex(t => t.paabgabe_id === termin.paabgabe_id)
this.projektarbeit.abgabetermine.splice(deletedTerminIndex, 1)
} else if(res?.meta?.status == 'error'){
this.$fhcAlert.alertError()
}
})
},
validate: function(termin) {
if(!termin.file.length) {
this.$fhcAlert.alertWarning(this.$p.t('global/warningChooseFile'));
return false
}
return true;
},
downloadAbgabe(termin) {
this.$fhcApi.factory.lehre.getStudentProjektarbeitAbgabeFile(termin.paabgabe_id, this.projektarbeit.student_uid)
},
dateDiffInDays(datum, today){
const oneDayMs = 1000 * 60 * 60 * 24
return Math.round((new Date(datum) - new Date(today)) / oneDayMs)
},
getDateStyle(termin) {
const datum = new Date(termin.datum)
const abgabedatum = new Date(termin.abgabedatum)
// todo: rework styling but keep the color pattern logic
// https://wiki.fhcomplete.info/doku.php?id=cis:abgabetool_fuer_studierende
let color = 'white'
let fontColor = 'black'
if (termin.abgabedatum === null) {
if(datum < today) {
color = 'red'
fontColor = 'white'
} else if (datum > today && this.dateDiffInDays(datum, today) <= 12) {
color = 'yellow'
}
} else if(abgabedatum > datum) {
color = 'pink' // aka "hellrot"
fontColor = 'white'
} else {
color = 'green'
}
return `font-color: ${fontColor} ; background-color: ${color}; border-radius: 50%;`
},
openBeurteilungLink(link) {
window.open(link, '_blank')
},
getOptionLabelSprache(option) {
return option.sprache
},
getOptionLabelAbgabetyp(option){
return option.bezeichnung
},
formatDate(dateParam) {
const date = new Date(dateParam)
// handle missing leading 0
const padZero = (num) => String(num).padStart(2, '0');
const month = padZero(date.getMonth() + 1); // Months are zero-based
const day = padZero(date.getDate());
const year = date.getFullYear();
return `${day}.${month}.${year}`;
},
openStudentPage() {
const link = FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/Cis/Abgabetool/Student/' + this.projektarbeit?.student_uid
window.open(link, '_blank')
},
openPlagiatcheck() {
// todo: hardcoded turnitin link?
const link = "https://technikum-wien.turnitin.com/sso/sp/redwood/saml/5IyfmBr2OcSIaWQTKlFCGj/start"
window.open(link, '_blank')
},
openBenotung() {
const path = this.projektarbeit?.betreuerart_kurzbz == 'Zweitbegutachter' ? 'ProjektarbeitsbeurteilungZweitbegutachter' : 'ProjektarbeitsbeurteilungErstbegutachter'
const link = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'index.ci.php/extensions/FHC-Core-Projektarbeitsbeurteilung/' + path
window.open(link, '_blank')
}
},
computed: {
getEid() {
return this.$p.t('abgabetool/c4eidesstattlicheErklaerung')
},
getEnduploadErlaubt() {
return !this.eidAkzeptiert
},
getSemesterBenotbar(){
return this.projektarbeit?.isCurrent ?? false
},
endUploadVorhanden(){
return this.projektarbeit?.abgabetermine.find(abgabe => abgabe.paabgabetyp_kurzbz === 'end' && abgabe.abgabedatum !== null)
}
},
created() {
},
mounted() {
},
template: `
<div v-if="projektarbeit">
<h5>{{$p.t('abgabetool/c4abgabeMitarbeiterbereich')}}</h5>
<div class="row">
<div class="col-8">
<p> {{projektarbeit?.student}}</p>
<p> {{projektarbeit?.titel}}</p>
<p v-if="projektarbeit?.zweitbegutachter"> {{projektarbeit?.zweitbegutachter}}</p>
</div>
<div class="col-4 d-flex">
<div class="col">
<div class="row">
<button :disabled="!getSemesterBenotbar || !endUploadVorhanden" class="btn btn-secondary border-0" @click="openBenotung" style="width: 80%;">
benoten
<i style="margin-left: 8px" class="fa-solid fa-user-check"></i>
</button>
</div>
<div class="row" style="width: 90%;">
<span v-if="!getSemesterBenotbar" v-html="$p.t('abgabetool/c4aeltereParbeitBenoten', oldPaBeurteilungLink)"></span>
<span v-else-if="!endUploadVorhanden">Kein Endupload vorhanden!</span>
</div>
</div>
<div class="col">
<div class="row">
<button v-if="projektarbeit?.betreuerart_kurzbz !== 'Zweitbegutachter'" class="btn btn-secondary border-0" @click="openPlagiatcheck" style="width: 80%;">
zur Plagiatsprüfung
<i style="margin-left: 8px" class="fa-solid fa-user-check"></i>
</button>
</div>
</div>
<div class="col">
<div class="row">
<button class="btn btn-secondary border-0" @click="openStudentPage" style="width: 80%;">
Studentenansicht
<i style="margin-left: 8px" class="fa-solid fa-eye"></i>
</button>
</div>
</div>
</div>
</div>
<div id="uploadWrapper">
<div class="row" style="margin-bottom: 12px;">
<div style="width: 100px">{{$p.t('abgabetool/c4fixtermin')}}</div>
<div class="col-2">{{$p.t('abgabetool/c4zieldatum')}}</div>
<div class="col-2">{{$p.t('abgabetool/c4abgabetyp')}}</div>
<div class="col-4">{{$p.t('abgabetool/c4abgabekurzbz')}}</div>
<div class="col-1">{{$p.t('abgabetool/c4abgabedatum')}}</div>
<div class="col">
</div>
</div>
<div v-if="!projektarbeit?.abgabetermine?.length">keine Termine gefunden!</div>
<div class="row" v-for="termin in projektarbeit.abgabetermine">
<div style="width: 100px" class="d-flex justify-content-center align-items-center">
<p class="fhc-bullet" :class="{ 'fhc-bullet-red': termin.fixtermin, 'fhc-bullet-green': !termin.fixtermin }"></p>
</div>
<div class="col-2 d-flex justify-content-center align-items-center">
<div :style="getDateStyle(termin)">
<VueDatePicker
style="width: 95%;"
v-model="termin.datum"
:clearable="false"
:disabled="!termin.allowedToSave"
:enable-time-picker="false"
:format="formatDate"
:text-input="true"
auto-apply>
</VueDatePicker>
</div>
</div>
<div class="col-2 d-flex justify-content-center align-items-center">
<Dropdown
:style="{'width': '100%'}"
:disabled="!termin.allowedToSave"
v-model="termin.bezeichnung"
:options="allAbgabeTypes"
:optionLabel="getOptionLabelAbgabetyp">
</Dropdown>
</div>
<div class="col-4 d-flex justify-content-center align-items-center">
<Textarea style="margin-bottom: 4px;" v-model="termin.kurzbz" rows="3" cols="60" :disabled="!termin.allowedToSave"></Textarea>
</div>
<div class="col-1 d-flex justify-content-center align-items-center">
{{ termin.abgabedatum?.split("-").reverse().join(".") }}
<a v-if="termin?.abgabedatum" @click="downloadAbgabe(termin)" style="margin-left:4px; cursor: pointer;">
<i class="fa-solid fa-file-pdf"></i>
</a>
</div>
<div class="col-2 align-content-center">
<div class="row">
<div class="col-6">
<button v-if="termin.allowedToSave" class="btn btn-primary border-0" @click="saveTermin(termin)">
Speichern
<i style="margin-left: 8px" class="fa-solid fa-floppy-disk"></i>
</button>
</div>
<div class="col-6">
<button v-if="termin.allowedToDelete && termin.paabgabe_id > 0" class="btn btn-primary border-0" @click="deleteTermin(termin)">
Löschen
<i style="margin-left: 8px" class="fa-solid fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`,
};
export default AbgabeMitarbeiterDetail;
@@ -0,0 +1,378 @@
import Upload from '../../../components/Form/Upload/Dms.js';
import BsModal from '../../Bootstrap/Modal.js';
import VueDatePicker from '../../vueDatepicker.js.php';
const today = new Date()
export const AbgabeStudentDetail = {
name: "AbgabeStudentDetail",
components: {
Upload,
BsModal,
InputNumber: primevue.inputnumber,
Checkbox: primevue.checkbox,
Dropdown: primevue.dropdown,
Textarea: primevue.textarea,
VueDatePicker
},
props: {
projektarbeit: {
type: Object,
default: null
},
viewMode: {
type: Boolean,
default: false
}
},
data() {
return {
eidAkzeptiert: false,
enduploadTermin: null,
allActiveLanguages: FHC_JS_DATA_STORAGE_OBJECT.server_languages,
form: Vue.reactive({
sprache: '',
abstract: '',
abstract_en: '',
schlagwoerter: '',
schlagwoerter_en: '',
kontrollschlagwoerter: '',
seitenanzahl: 1,
})
}
},
methods: {
validate: function(termin) {
if(!termin.file.length) {
this.$fhcAlert.alertWarning(this.$p.t('global/warningChooseFile'));
return false
}
return true;
},
triggerEndupload() {
if (!this.validate(this.enduploadTermin))
{
return false;
}
// post endabgabe
const formData = new FormData();
formData.append('paabgabetyp_kurzbz', this.enduploadTermin.paabgabetyp_kurzbz)
formData.append('projektarbeit_id', this.enduploadTermin.projektarbeit_id);
formData.append('paabgabe_id', this.enduploadTermin.paabgabe_id)
formData.append('student_uid', this.projektarbeit.student_uid)
formData.append('bperson_id', this.projektarbeit.bperson_id)
// TODO: validate/check for null etc.
formData.append('sprache', this.form['sprache'].sprache)
formData.append('abstract', this.form['abstract'])
formData.append('abstract_en', this.form['abstract_en'])
formData.append('schlagwoerter', this.form['schlagwoerter'])
formData.append('schlagwoerter_en', this.form['schlagwoerter_en'])
formData.append('seitenanzahl', this.form['seitenanzahl'])
for (let i = 0; i < this.enduploadTermin.file.length; i++) {
formData.append('file', this.enduploadTermin.file[i]);
}
this.$fhcApi.factory.lehre.postStudentProjektarbeitEndupload(formData)
.then(res => {
this.handleUploadRes(res)
})
this.$refs.modalContainerEnduploadZusatzdaten.hide()
},
downloadAbgabe(termin) {
this.$fhcApi.factory.lehre.getStudentProjektarbeitAbgabeFile(termin.paabgabe_id, this.projektarbeit.student_uid)
},
formatDate(dateParam) {
const date = new Date(dateParam)
// handle missing leading 0
const padZero = (num) => String(num).padStart(2, '0');
const month = padZero(date.getMonth() + 1); // Months are zero-based
const day = padZero(date.getDate());
const year = date.getFullYear();
return `${day}.${month}.${year}`;
},
upload(termin) {
if (!this.validate(termin))
{
return false;
}
if(termin.bezeichnung === 'Endupload') {
// open endupload form modal for further inputs
this.enduploadTermin = termin
this.$refs.modalContainerEnduploadZusatzdaten.show()
} else {
const formData = new FormData();
formData.append('paabgabetyp_kurzbz', termin.paabgabetyp_kurzbz)
formData.append('projektarbeit_id', this.projektarbeit.projektarbeit_id)
formData.append('paabgabe_id', termin.paabgabe_id)
formData.append('student_uid', this.projektarbeit.student_uid)
formData.append('bperson_id', this.projektarbeit.bperson_id)
for (let i = 0; i < termin.file.length; i++) {
formData.append('file', termin.file[i]);
}
this.$fhcApi.factory.lehre.postStudentProjektarbeitZwischenabgabe(formData)
.then(res => {
this.handleUploadRes(res)
})
}
},
handleUploadRes(res) {
if(res.meta.status == "success") {
this.$fhcAlert.alertSuccess('File erfolgreich hochgeladen')
} else {
this.$fhcAlert.alertError('File upload error')
}
if(res.meta.signaturInfo) {
this.$fhcAlert.alertInfo(res.meta.signaturInfo)
}
},
dateDiffInDays(datum, today){
const oneDayMs = 1000 * 60 * 60 * 24
return Math.round((new Date(datum) - new Date(today)) / oneDayMs)
},
getDateStyle(termin, mode) {
const datum = new Date(termin.datum)
const abgabedatum = new Date(termin.abgabedatum)
// todo: rework styling but keep the color pattern logic
// https://wiki.fhcomplete.info/doku.php?id=cis:abgabetool_fuer_studierende
let color = 'white'
let fontColor = 'black'
let icon = '';
if (termin.abgabedatum === null) {
if(datum < today) {
color = 'red'
fontColor = 'white'
icon = 'fa-triangle-exclamation'
} else if (datum > today && this.dateDiffInDays(datum, today) <= 12) {
color = 'yellow'
icon = 'fa-circle-exclamation'
}
} else if(abgabedatum > datum) {
color = 'pink' // aka "hellrot"
fontColor = 'white'
icon = 'fa-circle-question'
} else {
color = 'green'
icon = 'fa-square-check'
}
//return `font-color: ${fontColor} ; background-color: ${color}; border-radius: 50%;`
if( typeof mode !== 'undefined' || mode === 'icon') {
return icon;
} else {
return 'abgabe-zieldatum-border-' + color;
}
},
openBeurteilungLink(link) {
window.open(link, '_blank')
},
getOptionLabel(option) {
return option.sprache
}
},
watch: {
projektarbeit(newVal) {
// default select german if projektarbeit sprache was null
this.form.sprache = newVal.sprache ? this.allActiveLanguages.find(lang => lang.sprache == newVal.sprache) : this.allActiveLanguages.find(lang => lang.sprache == 'German')
this.form.abstract = newVal.abstract
this.form.abstract_en = newVal.abstract_en
this.form.schlagwoerter = newVal.schlagwoerter
this.form.schlagwoerter_en = newVal.schlagwoerter_en
this.form.kontrollschlagwoerter = newVal.kontrollschlagwoerter
this.form.seitenanzahl = newVal.seitenanzahl
}
},
computed: {
getEid() {
return this.$p.t('abgabetool/c4eidesstattlicheErklaerung')
},
getEnduploadErlaubt() {
return !this.eidAkzeptiert
}
},
created() {
},
mounted() {
},
template: `
<div v-if="projektarbeit">
<h5>{{$p.t('abgabetool/c4abgabeStudentenbereich')}}</h5>
<div class="row">
<p> {{projektarbeit?.betreuer}}</p>
<p> {{projektarbeit?.titel}}</p>
</div>
<div id="uploadWrapper">
<div class="row" style="margin-bottom: 12px;">
<div class="col-1 fw-bold text-center">{{$p.t('abgabetool/c4fixtermin')}}</div>
<div class="col-2 fw-bold">{{$p.t('abgabetool/c4zieldatum')}}</div>
<div class="col-2 fw-bold">{{$p.t('abgabetool/c4abgabetyp')}}</div>
<div class="col-3 fw-bold">{{$p.t('abgabetool/c4abgabekurzbz')}}</div>
<div class="col-1 fw-bold text-center">{{$p.t('abgabetool/c4abgabedatum')}}</div>
<div class="col-3 fw-bold">
{{$p.t('abgabetool/c4fileupload')}}
</div>
</div>
<div class="row" v-for="termin in projektarbeit.abgabetermine">
<div class="col-1 d-flex justify-content-center align-items-start">
<i v-if="termin.fixtermin" class="fa-solid fa-2x fa-circle-check fhc-bullet-red"></i>
<i v-else="" class="fa-solid fa-2x fa-circle-xmark fhc-bullet-green"></i>
<!--
<p class="fhc-bullet" :class="{ 'fhc-bullet-red': termin.fixtermin, 'fhc-bullet-green': !termin.fixtermin }"></p>
-->
</div>
<div class="col-2 d-flex justify-content-start align-items-start">
<div class="position-relative" :class="getDateStyle(termin)">
<VueDatePicker
v-model="termin.datum"
:clearable="false"
:disabled="true"
:enable-time-picker="false"
:format="formatDate"
:text-input="true"
auto-apply>
</VueDatePicker>
<i class="position-absolute abgabe-zieldatum-overlay fa-solid fa-2x" :class="getDateStyle(termin, 'icon')"></i>
</div>
</div>
<div class="col-2 d-flex justify-content-start align-items-start">{{ termin.bezeichnung }}</div>
<div class="col-3 d-flex justify-content-start align-items-start">
<Textarea style="margin-bottom: 4px;" v-model="termin.kurzbz" rows="3" cols="45" :disabled="true"></Textarea>
</div>
<div class="col-1 d-flex flex-column justify-content-start align-items-center">
{{ termin.abgabedatum?.split("-").reverse().join(".") }}
<a v-if="termin?.abgabedatum" @click="downloadAbgabe(termin)" style="margin-left:4px; cursor: pointer;">
<i class="fa-solid fa-3x fa-file-pdf"></i>
</a>
</div>
<div class="col-3" v-if="!viewMode">
<div class="row">
<div class="col-8">
<Upload v-if="termin && termin.allowedToUpload" accept=".pdf" v-model="termin.file"></Upload>
</div>
<div class="col-4">
<button class="btn btn-primary border-0" @click="upload(termin)" :disabled="!termin.allowedToUpload">
Upload
<i style="margin-left: 8px" class="fa-solid fa-upload"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<bs-modal ref="modalContainerEnduploadZusatzdaten" class="bootstrap-prompt" dialogClass="modal-lg">
<template v-slot:title>
<div>
{{$p.t('abgabetool/c4enduploadZusatzdaten')}}
</div>
<div class="row mb-3 align-items-start">
<p class="ml-4 mr-4">Student UID: {{ projektarbeit?.student_uid}}</p>
</div>
<div class="row mb-3 align-items-start">
<p class="ml-4 mr-4">Titel: {{ projektarbeit?.titel }}</p>
</div>
</template>
<template v-slot:default>
<div class="row mb-3 align-items-start">
<div class="row">{{$p.t('abgabetool/c4Sprache')}}</div>
<div class="row">
<Dropdown
:style="{'width': '100%'}"
v-model="form.sprache"
:options="allActiveLanguages"
:optionLabel="getOptionLabel">
</Dropdown>
</div>
</div>
<!-- lektor fills these out-->
<!-- <div class="row mb-3 align-items-start">-->
<!-- <div class="row">Kontrollierte Schlagwörter</div>-->
<!-- <div class="row">-->
<!-- <Textarea v-model="form.kontrollschlagwoerter"></Textarea>-->
<!-- </div>-->
<!-- -->
<!-- -->
<!-- </div>-->
<div class="row mb-3 align-items-start">
<div class="row">{{$p.t('abgabetool/c4schlagwoerterGer')}}</div>
<div class="row">
<Textarea v-model="form.schlagwoerter"></Textarea>
</div>
</div>
<div class="row mb-3 align-items-start">
<div class="row">{{$p.t('abgabetool/c4schlagwoerterEng')}}</div>
<div class="row">
<Textarea v-model="form.schlagwoerter_en"></Textarea>
</div>
</div>
<div class="row mb-3 align-items-start">
<div class="row">{{$p.t('abgabetool/c4abstractGer')}}</div>
<div class="row">
<Textarea v-model="form.abstract" rows="10"></Textarea>
</div>
</div>
<div class="row mb-3 align-items-start">
<div class="row">{{$p.t('abgabetool/c4abstractEng')}}</div>
<div class="row">
<Textarea v-model="form.abstract_en" rows="10"></Textarea>
</div>
</div>
<div class="row mb-3 align-items-start">
<div class="row">{{$p.t('abgabetool/c4seitenanzahl')}}</div>
<div class="row">
<InputNumber
v-model="form.seitenanzahl"
inputId="seitenanzahlInput" :min="1" :max="100000">
</InputNumber>
</div>
</div>
<div v-if="projektarbeit">
<div v-html="getEid"></div>
<div class="row">
<div class="col-9"></div>
<div class="col-2"><p>{{ $p.t('abgabetool/c4gelesenUndAkzeptiert') }}</p></div>
<div class="col-1">
<Checkbox
v-model="eidAkzeptiert"
:binary="true"
:pt="{ root: { class: 'ml-auto' }}"
>
</Checkbox>
</div>
</div>
</div>
</template>
<template v-slot:footer>
<button class="btn btn-primary" :disabled="getEnduploadErlaubt" @click="triggerEndupload">{{$p.t('ui/hochladen')}}</button>
</template>
</bs-modal>
`,
};
export default AbgabeStudentDetail;
@@ -0,0 +1,483 @@
import {CoreFilterCmpt} from "../../../components/filter/Filter.js";
import AbgabeDetail from "./AbgabeMitarbeiterDetail.js";
import VerticalSplit from "../../verticalsplit/verticalsplit.js"
import BsModal from '../../Bootstrap/Modal.js';
import VueDatePicker from '../../vueDatepicker.js.php';
export const AbgabetoolMitarbeiter = {
name: "AbgabetoolMitarbeiter",
components: {
BsModal,
CoreFilterCmpt,
AbgabeDetail,
VerticalSplit,
Dropdown: primevue.dropdown,
Textarea: primevue.textarea,
VueDatePicker
},
props: {
viewData: {
type: Object,
required: true,
default: () => ({name: '', uid: ''}),
validator(value) {
return value && value.name && value.uid
}
}
},
data() {
return {
saving: false,
loading: false,
// TODO: fetch types
allAbgabeTypes: [
{
paabgabetyp_kurzbz: 'abstract',
bezeichnung: 'Entwurf'
},
{
paabgabetyp_kurzbz: 'zwischen',
bezeichnung: 'Zwischenabgabe'
},
{
paabgabetyp_kurzbz: 'note',
bezeichnung: 'Benotung'
},
{
paabgabetyp_kurzbz: 'end',
bezeichnung: 'Endupload'
},
{
paabgabetyp_kurzbz: 'enda',
bezeichnung: 'Endabgabe im Sekretariat'
}
],
serienTermin: Vue.reactive({
datum: new Date(),
bezeichnung: {
paabgabetyp_kurzbz: 'zwischen',
bezeichnung: 'Zwischenabgabe'
},
kurzbz: ''
}),
showAll: false,
tabulatorUuid: Vue.ref(0),
selectedData: [],
domain: '',
student_uid: null,
detail: null,
detailOffset: 0,
projektarbeiten: null,
selectedProjektarbeit: null,
tableBuiltResolve: null,
tableBuiltPromise: null,
abgabeTableOptions: {
height: 700,
index: 'projektarbeit_id',
layout: 'fitDataStretch',
placeholder: this.$p.t('global/noDataAvailable'),
selectable: true,
selectableCheck: this.selectionCheck,
columns: [
{
formatter: 'rowSelection',
titleFormatter: 'rowSelection',
titleFormatterParams: {
rowRange: "active" // Only toggle the values of the active filtered rows
},
hozAlign:"center",
headerSort: false,
frozen: true,
width: 70
},
{title: Vue.computed(() => this.$p.t('abgabetool/c4details')), field: 'details', formatter: this.detailFormatter, widthGrow: 1, tooltip: false},
{title: Vue.computed(() => this.$p.t('abgabetool/c4personenkennzeichen')), field: 'pkz', formatter: this.pkzTextFormatter, widthGrow: 1, tooltip: false},
{title: Vue.computed(() => this.$p.t('abgabetool/c4kontakt')), field: 'mail', formatter: this.mailFormatter, widthGrow: 1, tooltip: false},
{title: Vue.computed(() => this.$p.t('abgabetool/c4vorname')), field: 'vorname', formatter: this.centeredTextFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$p.t('abgabetool/c4nachname')), field: 'nachname', formatter: this.centeredTextFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$p.t('abgabetool/c4projekttyp')), field: 'projekttyp_kurzbz', formatter: this.centeredTextFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$p.t('abgabetool/c4stg')), field: 'stg', formatter: this.centeredTextFormatter, widthGrow: 2},
{title: Vue.computed(() => this.$p.t('abgabetool/c4sem')), field: 'studiensemester_kurzbz', formatter: this.centeredTextFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$p.t('abgabetool/c4titel')), field: 'titel', formatter: this.centeredTextFormatter, maxWidth: 500, widthGrow: 8},
{title: Vue.computed(() => this.$p.t('abgabetool/c4betreuerart')), field: 'betreuerart_beschreibung',formatter: this.centeredTextFormatter, widthGrow: 8}
],
persistence: false,
},
abgabeTableEventHandlers: [{
event: "tableBuilt",
handler: async () => {
this.tableBuiltResolve()
}
},
{
event: "cellClick",
handler: async (e, cell) => {
if(cell.getColumn().getField() === "details") {
this.setDetailComponent(cell.getValue())
this.undoSelection(cell)
} else if (cell.getColumn().getField() === "mail") {
this.undoSelection(cell)
}
}
},
{
event: "rowSelectionChanged",
handler: async(data) => {
this.selectedData = data
}
}
]};
},
methods: {
getOptionLabelAbgabetyp(option){
return option.bezeichnung
},
formatDate(dateParam) {
const date = new Date(dateParam)
// handle missing leading 0
const padZero = (num) => String(num).padStart(2, '0');
const month = padZero(date.getMonth() + 1); // Months are zero-based
const day = padZero(date.getDate());
const year = date.getFullYear();
return `${day}.${month}.${year}`;
},
undoSelection(cell) {
// checks if cells row is selected and unselects -> imitates columns which dont trigger row selection
// but actually just revert it after the fact
const row = cell.getRow()
if(row.isSelected()) {
row.deselect();
}
},
selectionCheck(row) {
const data = row.getData()
if(data?.betreuerart_kurzbz == 'Zweitbegutachter') return false
return true
},
showDeadlines(){
const link = FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/Cis/Abgabetool/Deadlines'
window.open(link, '_blank')
},
toggleShowAll(showall) {
this.showAll = showall
this.loading = true
this.loadProjektarbeiten(showall, () => {
this.$refs.abgabeTable?.tabulator.redraw(true)
this.$refs.abgabeTable?.tabulator.setSort([]);
this.loading = false
})
},
openAddSeriesModal() {
this.$refs.modalContainerAddSeries.show()
},
addSeries() {
this.saving = true
this.$fhcApi.factory.lehre.postSerientermin(
this.serienTermin.datum.toISOString(),
this.serienTermin.bezeichnung.paabgabetyp_kurzbz,
this.serienTermin.bezeichnung.bezeichnung,
this.serienTermin.kurzbz,
this.selectedData?.map(projekt => projekt.projektarbeit_id)
).then(res => {
if (res.meta.status === "success" && res.data) {
this.$fhcAlert.alertSuccess(this.$p.t('abgabetool/serienTerminGespeichert'))
// TODO: sticky lifetime erhöhen um sinnvoll lesen zu können?
this.$fhcAlert.alertInfo(this.$p.t('abgabetool/serienTerminEmailSentInfo', [this.createInfoString(res.data)]));
} else {
this.$fhcAlert.alertError(this.$p.t('abgabetool/errorSerienterminSpeichern'))
}
}).finally(()=>{
this.saving = false
})
this.$refs.modalContainerAddSeries.hide()
},
createInfoString(data) {
let str = '';
data.forEach(name => {
str += name
str += '; '
})
return str
},
isPastDate(date) {
return new Date(date) < new Date(Date.now())
},
setDetailComponent(details){
this.loadAbgaben(details).then((res)=> {
const pa = this.projektarbeiten?.retval?.find(projekarbeit => projekarbeit.projektarbeit_id == details.projektarbeit_id)
pa.abgabetermine = res.data[0].retval
pa.isCurrent = res.data[1]
pa.abgabetermine.push({ // new abgatermin row
'paabgabe_id': -1,
'projektarbeit_id': pa.projektarbeit_id,
'fixtermin': false,
'kurzbz': '',
'datum': new Date().toISOString().split('T')[0],
'paabgabetyp_kurzbz': '',
'bezeichnung': '',
'abgabedatum': null,
'insertvon': this.viewData?.uid ?? ''
})
pa.abgabetermine.forEach(termin => {
termin.file = []
termin.allowedToSave = termin.insertvon == this.viewData?.uid && pa.betreuerart_kurzbz != 'Zweitbegutachter'
termin.allowedToDelete = termin.allowedToSave && !termin.abgabedatum
termin.bezeichnung = {
bezeichnung: termin.bezeichnung,
paabgabetyp_kurzbz: termin.paabgabetyp_kurzbz
}
})
pa.betreuer = this.buildBetreuer(pa)
pa.student_uid = details.student_uid
pa.student = `${pa.vorname} ${pa.nachname}`
this.selectedProjektarbeit = pa
this.$refs.verticalsplit.showBoth()
})
},
centeredTextFormatter(cell) {
const val = cell.getValue()
if(!val) return
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<p style="max-width: 100%; width: 100%; overflow-wrap: break-word; word-break: break-word; white-space: normal; margin: 0px; text-align: center">'+val+'</p></div>'
},
detailFormatter(cell) {
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<a><i class="fa fa-folder-open" style="color:#00649C"></i></a></div>'
},
mailFormatter(cell) {
const val = cell.getValue()
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<a href='+val+'><i class="fa fa-envelope" style="color:#00649C"></i></a></div>'
},
beurteilungFormatter(cell) {
const val = cell.getValue()
if(val) {
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<a><i class="fa fa-file-pdf" style="color:#00649C"></i></a></div>'
} else return '-'
},
pkzTextFormatter(cell) {
const val = cell.getValue()
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<p style="max-width: 100%; word-wrap: break-word; white-space: normal;">'+val+'</p></div>'
},
tableResolve(resolve) {
this.tableBuiltResolve = resolve
},
buildMailToLink(abgabe) {
return 'mailto:' + abgabe.uid +'@'+ this.domain
},
buildPKZ(projekt) {
return `${projekt.uid} / ${projekt.matrikelnr}`
},
buildStg(projekt) {
return (projekt.typ + projekt.kurzbz)?.toUpperCase()
},
buildBetreuer(abgabe) {
// TODO: preload and insert own titled name of betreuer somehow
return abgabe.betreuerart_beschreibung + ': ' + (abgabe.btitelpre ? abgabe.btitelpre + ' ' : '') + abgabe.bvorname + ' ' + abgabe.bnachname + (abgabe.btitelpost ? ' ' + abgabe.btitelpost : '')
},
setupData(data){
this.projektarbeiten = data[0]
this.domain = data[1]
const d = data[0]?.retval?.map(projekt => {
let mode = 'detailTermine'
return {
...projekt,
details: {
student_uid: projekt.uid,
projektarbeit_id: projekt.projektarbeit_id,
},
pkz: this.buildPKZ(projekt),
beurteilung: projekt.beurteilungLink ?? null,
sem: projekt.studiensemester_kurzbz,
stg: this.buildStg(projekt),
mail: this.buildMailToLink(projekt),
typ: projekt.projekttyp_kurzbz,
titel: projekt.titel
}
})
this.$refs.abgabeTable.tabulator.setColumns(this.abgabeTableOptions.columns)
this.$refs.abgabeTable.tabulator.setData(d);
},
loadProjektarbeiten(all = false, callback) {
this.$fhcApi.factory.lehre.getMitarbeiterProjektarbeiten(this.viewData?.uid ?? null, all)
.then(res => {
if(res?.data) this.setupData(res.data)
}).finally(() => {
if(callback) {
callback()
}
})
},
loadAbgaben(details) {
return new Promise((resolve) => {
this.$fhcApi.factory.lehre.getStudentProjektabgaben(details)
.then(res => {
resolve(res)
})
})
},
handleUuidDefined(uuid) {
this.tabulatorUuid = uuid
},
calcMaxTableHeight() {
const tableID = this.tabulatorUuid ? ('-' + this.tabulatorUuid) : ''
const tableDataSet = document.getElementById('filterTableDataset' + tableID);
if(!tableDataSet) return
const rect = tableDataSet.getBoundingClientRect();
this.abgabeTableOptions.height = window.visualViewport.height - rect.top
this.$refs.abgabeTable.tabulator.setHeight(this.abgabeTableOptions.height)
},
async setupMounted() {
this.tableBuiltPromise = new Promise(this.tableResolve)
await this.tableBuiltPromise
this.loadProjektarbeiten()
this.$refs.verticalsplit.collapseBottom()
this.calcMaxTableHeight()
}
},
watch: {
},
computed: {
},
created() {
},
mounted() {
this.setupMounted()
},
template: `
<bs-modal ref="modalContainerAddSeries" class="bootstrap-prompt"
dialogClass="modal-lg">
<template v-slot:title>
<div>
{{ $p.t('abgabetool/neueTerminserie') }}
</div>
</template>
<template v-slot:default>
<div class="row">
<div class="col-3 d-flex justify-content-center align-items-center">
{{$p.t('abgabetool/c4zieldatum')}}
</div>
<div class="col-3 d-flex justify-content-center align-items-center">
{{$p.t('abgabetool/c4abgabetyp')}}
</div>
<div class="col-6 d-flex justify-content-center align-items-center">
{{$p.t('abgabetool/c4abgabekurzbz')}}
</div>
</div>
<div class="row">
<div class="col-3 d-flex justify-content-center align-items-center">
<div>
<VueDatePicker
style="width: 95%;"
v-model="serienTermin.datum"
:clearable="false"
:enable-time-picker="false"
:format="formatDate"
:text-input="true"
auto-apply>
</VueDatePicker>
</div>
</div>
<div class="col-3 d-flex justify-content-center align-items-center">
<Dropdown
:style="{'width': '100%'}"
v-model="serienTermin.bezeichnung"
:options="allAbgabeTypes"
:optionLabel="getOptionLabelAbgabetyp">
</Dropdown>
</div>
<div class="col-6 d-flex justify-content-center align-items-center">
<Textarea style="margin-bottom: 4px;" v-model="serienTermin.kurzbz" rows="3" cols="40"></Textarea>
</div>
</div>
</template>
<template v-slot:footer>
<button type="button" class="btn btn-primary" @click="addSeries">{{ $p.t('global/speichern') }}</button>
</template>
</bs-modal>
<vertical-split ref="verticalsplit">
<template #top>
<h2>{{$p.t('abgabetool/abgabetoolTitle')}}</h2>
<hr>
<core-filter-cmpt
:title="''"
@uuidDefined="handleUuidDefined"
ref="abgabeTable"
:newBtnShow="true"
:newBtnLabel="$p.t('abgabetool/neueTerminserie')"
:newBtnDisabled="!selectedData.length"
@click:new=openAddSeriesModal
:tabulator-options="abgabeTableOptions"
:tabulator-events="abgabeTableEventHandlers"
tableOnly
:sideMenu="false"
:useSelectionSpan="false"
>
<template #actions>
<button @click="toggleShowAll(!showAll)" role="button" class="btn btn-secondary ml-2">
<i v-show="!showAll" class="fa fa-eye"></i>
<i v-show="showAll" class="fa fa-eye-slash"></i>
{{ $p.t('abgabetool/showAll') }}
</button>
<button @click="showDeadlines" role="button" class="btn btn-secondary ml-2">
<i class="fa fa-hourglass-end"></i>
{{ $p.t('abgabetool/showDeadlines') }}
</button>
<div v-show="saving">
{{ $p.t('abgabetool/currentlySaving') }} <i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
<div v-show="loading">
{{ $p.t('abgabetool/currentlyLoading') }} <i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
</template>
</core-filter-cmpt>
</template>
<template #bottom>
<div v-show="selectedProjektarbeit" ref="selProj">
<AbgabeDetail :projektarbeit="selectedProjektarbeit"></AbgabeDetail>
</div>
</template>
</vertical-split>
`,
};
export default AbgabetoolMitarbeiter;
@@ -0,0 +1,264 @@
import {CoreFilterCmpt} from "../../../components/filter/Filter.js";
import AbgabeDetail from "./AbgabeStudentDetail.js";
import VerticalSplit from "../../verticalsplit/verticalsplit.js";
export const AbgabetoolStudent = {
name: "AbgabetoolStudent",
components: {
CoreFilterCmpt,
AbgabeDetail,
VerticalSplit
},
props: {
student_uid_prop: {
default: null
},
viewData: {
type: Object,
required: true,
default: () => ({uid: ''}),
validator(value) {
return value && value.uid
}
}
},
data() {
return {
tabulatorUuid: Vue.ref(0),
domain: '',
student_uid: null,
detail: null,
projektarbeiten: null,
selectedProjektarbeit: null,
tableBuiltResolve: null,
tableBuiltPromise: null,
abgabeTableOptions: {
minHeight: 250,
index: 'projektarbeit_id',
layout: 'fitColumns',
placeholder: this.$p.t('global/noDataAvailable'),
columns: [
{title: Vue.computed(() => this.$p.t('abgabetool/c4details')), field: 'details', formatter: this.detailFormatter, widthGrow: 1, tooltip: false},
{title: Vue.computed(() => this.$p.t('abgabetool/c4beurteilung')), field: 'beurteilung', formatter: this.beurteilungFormatter, widthGrow: 1, tooltip: false},
{title: Vue.computed(() => this.$p.t('abgabetool/c4sem')), field: 'sem', formatter: this.centeredTextFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$p.t('abgabetool/c4stg')), field: 'stg', formatter: this.centeredTextFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$p.t('abgabetool/c4kontakt')), field: 'mail', formatter: this.mailFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$p.t('abgabetool/c4betreuer')), field: 'betreuer', formatter: this.centeredTextFormatter,widthGrow: 2},
{title: Vue.computed(() => this.$p.t('abgabetool/c4projekttyp')), field: 'typ', formatter: this.centeredTextFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$p.t('abgabetool/c4titel')), field: 'titel', formatter: this.centeredTextFormatter, widthGrow: 8}
],
persistence: false,
},
abgabeTableEventHandlers: [{
event: "tableBuilt",
handler: async () => {
this.tableBuiltResolve()
}
},
{
event: "cellClick",
handler: async (e, cell) => {
if(cell.getColumn().getField() === "details") {
const val = cell.getValue()
if(val.mode === 'detailTermine') {
this.setDetailComponent(cell.getValue())
} else if (val.mode === 'beurteilungDownload') {
const pdfExportLink = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cis/private/pdfExport.php?xml=projektarbeitsbeurteilung.xml.php&xsl=Projektbeurteilung&betreuerart_kurzbz='+val.betreuerart_kurzbz+'&projektarbeit_id='+val.projektarbeit_id+'&person_id=' + val.betreuer_person_id
// const pdfExportLink2 = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cis/private/lehre/projektbeurteilungDocumentExport.php?betreuerart_kurzbz='+val.betreuerart_kurzbz+'&projektarbeit_id='+val.projektarbeit_id+'&person_id=' + val.betreuer_person_id
window.open(pdfExportLink, '_blank')
}
} else if (cell.getColumn().getField() === "beurteilung") {
const val = cell.getValue()
if(val != '-') window.open(val, '_blank')
}
e.stopPropagation()
}
}
]};
},
methods: {
isPastDate(date) {
return new Date(date) < new Date(Date.now())
},
setDetailComponent(details){
this.loadAbgaben(details).then((res)=> {
const pa = this.projektarbeiten?.retval?.find(projekarbeit => projekarbeit.projektarbeit_id == details.projektarbeit_id)
pa.abgabetermine = res.data[0].retval
pa.abgabetermine.forEach(termin => {
termin.file = []
termin.allowedToUpload = true
// TODO: fixtermin logic?
if(termin.bezeichnung == 'Endupload' && this.isPastDate(termin.datum)) {
// termin.allowedToUpload = false
} else {
// termin.allowedToUpload = true
}
})
pa.betreuer = this.buildBetreuer(pa)
pa.student_uid = this.student_uid
this.selectedProjektarbeit = pa
this.$refs.verticalsplit.showBoth()
})
},
centeredTextFormatter(cell) {
const val = cell.getValue()
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<p style="max-width: 100%; word-wrap: break-word; white-space: normal;">'+val+'</p></div>'
},
detailFormatter(cell) {
const val = cell.getValue()
if(val.mode === 'detailTermine') {
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<a><i class="fa fa-folder-open" style="color:#00649C"></i></a></div>'
} else if (val.mode === 'beurteilungDownload') {
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<a><i class="fa fa-file-pdf" style="color:#00649C"></i></a></div>'
}
},
mailFormatter(cell) {
const val = cell.getValue()
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<a href='+val+'><i class="fa fa-envelope" style="color:#00649C"></i></a></div>'
},
beurteilungFormatter(cell) {
const val = cell.getValue()
if(val) {
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<a><i class="fa fa-file-pdf" style="color:#00649C"></i></a></div>'
} else return '-'
},
tableResolve(resolve) {
this.tableBuiltResolve = resolve
},
buildMailToLink(abgabe) {
return 'mailto:' + abgabe.mitarbeiter_uid +'@'+ this.domain
},
buildBetreuer(abgabe) {
return abgabe.betreuerart_beschreibung + ': ' + (abgabe.btitelpre ? abgabe.btitelpre + ' ' : '') + abgabe.bvorname + ' ' + abgabe.bnachname + (abgabe.btitelpost ? ' ' + abgabe.btitelpost : '')
},
setupData(data){
this.projektarbeiten = data[0]
this.domain = data[1]
this.student_uid = data[2]
const d = data[0]?.retval?.map(projekt => {
let mode = 'detailTermine'
if (projekt.babgeschickt || projekt.zweitbetreuer_abgeschickt) {
// mode = 'beurteilungDownload' // build dl link for both betreuer documents
projekt.beurteilungLink = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cis/private/pdfExport.php?xml=projektarbeitsbeurteilung.xml.php&xsl=Projektbeurteilung&betreuerart_kurzbz='+projekt.betreuerart_kurzbz+'&projektarbeit_id='+projekt.projektarbeit_id+'&person_id=' + projekt.bperson_id
}
return {
details: {
student_uid: this.student_uid,
projektarbeit_id: projekt.projektarbeit_id,
betreuer_person_id: projekt.bperson_id,
betreuerart_kurzbz: projekt.betreuerart_kurzbz,
mode
},
beurteilung: projekt.beurteilungLink ?? null,
sem: projekt.studiensemester_kurzbz,
stg: projekt.kurzbzlang,
mail: this.buildMailToLink(projekt),
betreuer: this.buildBetreuer(projekt),
typ: projekt.projekttypbezeichnung,
titel: projekt.titel
}
})
this.$refs.abgabeTable.tabulator.setColumns(this.abgabeTableOptions.columns)
this.$refs.abgabeTable.tabulator.setData(d);
},
loadProjektarbeiten() {
this.$fhcApi.factory.lehre.getStudentProjektarbeiten(this.student_uid_prop || this.viewData?.uid || null)
.then(res => {
if(res?.data) this.setupData(res.data)
})
},
loadAbgaben(details) {
return new Promise((resolve) => {
this.$fhcApi.factory.lehre.getStudentProjektabgaben(details)
.then(res => {
resolve(res)
})
})
},
handleUuidDefined(uuid) {
this.tabulatorUuid = uuid
},
calcMaxTableHeight() {
const tableID = this.tabulatorUuid ? ('-' + this.tabulatorUuid) : ''
const tableDataSet = document.getElementById('filterTableDataset' + tableID);
if(!tableDataSet) return
const rect = tableDataSet.getBoundingClientRect();
this.abgabeTableOptions.height = window.visualViewport.height - rect.top
this.$refs.abgabeTable.tabulator.setHeight(this.abgabeTableOptions.height)
},
async setupMounted() {
this.tableBuiltPromise = new Promise(this.tableResolve)
await this.tableBuiltPromise
this.loadProjektarbeiten()
this.$refs.verticalsplit.collapseBottom()
//this.calcMaxTableHeight()
}
},
watch: {
},
computed: {
isViewMode() {
return this.student_uid !== this.viewData.uid
}
},
created() {
},
mounted() {
this.setupMounted()
},
template: `
<vertical-split ref="verticalsplit">
<template #top>
<h2>{{$p.t('abgabetool/abgabetoolTitle')}}</h2>
<hr>
<core-filter-cmpt
@uuidDefined="handleUuidDefined"
:title="''"
ref="abgabeTable"
:tabulator-options="abgabeTableOptions"
:tabulator-events="abgabeTableEventHandlers"
tableOnly
:sideMenu="false"
/>
</template>
<template #bottom>
<div v-show="selectedProjektarbeit">
<AbgabeDetail :viewMode="isViewMode" :projektarbeit="selectedProjektarbeit"></AbgabeDetail>
</div>
</template>
</vertical-split>
`,
};
export default AbgabetoolStudent;
@@ -0,0 +1,151 @@
import {CoreFilterCmpt} from "../../../components/filter/Filter.js";
export const DeadlineOverview = {
name: "DeadlineOverview",
components: {
CoreFilterCmpt,
},
props: {
person_uid_prop: {
default: null
},
viewData: {
type: Object,
required: true,
default: () => ({name: '', uid: ''}),
validator(value) {
return value && value.name && value.uid
}
}
},
data() {
return {
fullName: null, // TODO: fetch this somewhere
deadlines: null,
tabulatorUuid: Vue.ref(0),
tableBuiltResolve: null,
tableBuiltPromise: null,
deadlineTableOptions: {
height: 700,
index: 'projektarbeit_id',
layout: 'fitColumns',
placeholder: this.$p.t('global/noDataAvailable'),
columns: [
{title: Vue.computed(() => this.$p.t('abgabetool/c4zieldatum')), field: 'datum', formatter: this.centeredTextFormatter, widthGrow: 1, tooltip: false},
{title: Vue.computed(() => this.$p.t('abgabetool/c4fixtermin')), field: 'fixterminstring', formatter: this.centeredTextFormatter, widthGrow: 1, tooltip: false},
{title: Vue.computed(() => this.$p.t('abgabetool/c4abgabetyp')), field: 'typ_bezeichnung', formatter: this.centeredTextFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$p.t('abgabetool/c4abgabekurzbz')), field: 'kurzbz', formatter: this.centeredTextFormatter, widthGrow: 3},
{title: Vue.computed(() => this.$p.t('person/studentIn')), field: 'student', formatter: this.centeredTextFormatter, widthGrow: 2},
{title: Vue.computed(() => this.$p.t('abgabetool/c4stg')), field: 'stg', formatter: this.centeredTextFormatter,widthGrow: 1},
{title: Vue.computed(() => this.$p.t('abgabetool/c4sem')), field: 'semester', formatter: this.centeredTextFormatter, widthGrow: 1}
],
persistence: false,
},
deadlineTableEventHandlers: [{
event: "tableBuilt",
handler: async () => {
this.tableBuiltResolve()
}
},
{
event: "cellClick",
handler: async (e, cell) => {
if(cell.getColumn().getField() === "details") {
const val = cell.getValue()
if(val.mode === 'detailTermine') {
this.setDetailComponent(cell.getValue())
} else if (val.mode === 'beurteilungDownload') {
const pdfExportLink = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cis/private/pdfExport.php?xml=projektarbeitsbeurteilung.xml.php&xsl=Projektbeurteilung&betreuerart_kurzbz='+val.betreuerart_kurzbz+'&projektarbeit_id='+val.projektarbeit_id+'&person_id=' + val.betreuer_person_id
// const pdfExportLink2 = FHC_JS_DATA_STORAGE_OBJECT.app_root + 'cis/private/lehre/projektbeurteilungDocumentExport.php?betreuerart_kurzbz='+val.betreuerart_kurzbz+'&projektarbeit_id='+val.projektarbeit_id+'&person_id=' + val.betreuer_person_id
window.open(pdfExportLink, '_blank')
}
} else if (cell.getColumn().getField() === "beurteilung") {
const val = cell.getValue()
if(val != '-') window.open(val, '_blank')
}
e.stopPropagation()
}
}
]};
},
methods: {
centeredTextFormatter(cell) {
const val = cell.getValue()
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<p style="max-width: 100%; word-wrap: break-word; white-space: normal;">'+val+'</p></div>'
},
tableResolve(resolve) {
this.tableBuiltResolve = resolve
},
loadDeadlines() {
this.$fhcApi.factory.lehre.fetchDeadlines(this.person_uid_prop ?? null)
.then(res => {
if(res?.data) this.setupData(res.data)
})
},
setupData(data) {
this.deadlines = data
this.deadlines.forEach(dl => {
dl.student = (dl.stud_titelpre ? (dl.stud_titelpre + ' ') :'') + dl.stud_vorname + ' ' + dl.stud_nachname + (dl.stud_titelpost ? (' ' + dl.stud_titelpost) :'')
dl.fixterminstring = dl.fixtermin ? this.$p.t('abgabetool/c4yes') : this.$p.t('abgabetool/c4no')
})
this.$refs.deadlineTable.tabulator.setColumns(this.deadlineTableOptions.columns)
this.$refs.deadlineTable.tabulator.setData(this.deadlines);
},
handleUuidDefined(uuid) {
this.tabulatorUuid = uuid
},
calcMaxTableHeight() {
const tableID = this.tabulatorUuid ? ('-' + this.tabulatorUuid) : ''
const tableDataSet = document.getElementById('filterTableDataset' + tableID);
if(!tableDataSet) return
const rect = tableDataSet.getBoundingClientRect();
this.deadlineTableOptions.height = window.visualViewport.height - rect.top
this.$refs.deadlineTable.tabulator.setHeight(this.deadlineTableOptions.height)
},
async setupMounted() {
this.tableBuiltPromise = new Promise(this.tableResolve)
await this.tableBuiltPromise
this.loadDeadlines()
this.calcMaxTableHeight()
}
},
watch: {
},
computed: {
},
created() {
},
mounted() {
this.setupMounted()
},
template: `
<h2>{{$p.t('abgabetool/deadlinesTitle')}} {{ fullName ? ('-' + fullName) : ''}}</h2>
<hr>
<core-filter-cmpt
@uuidDefined="handleUuidDefined"
:title="''"
ref="deadlineTable"
:tabulator-options="deadlineTableOptions"
:tabulator-events="deadlineTableEventHandlers"
tableOnly
:sideMenu="false"
/>
`,
};
export default DeadlineOverview;
+103
View File
@@ -0,0 +1,103 @@
import raum_contentmittitel from './Content_types/Raum_contentmittitel.js'
import general from './Content_types/General.js'
import BsConfirm from "../../Bootstrap/Confirm.js";
import news_content from './Content_types/News_content.js';
import ApiCms from '../../../api/factory/cms.js';
export default {
name: "ContentComponent",
props: {
content_id: {
type: [Number, String],
required: true
},
version: {
type: [String, Number],
default: null,
},
sichtbar: {
type: [String, Number],
default: null,
}
},
components: {
raum_contentmittitel,
news_content,
general,
},
data() {
return {
content: null,
content_id_internal: this.content_id
};
},
methods: {
fetchContent(){
return this.$api
.call(ApiCms.content(this.content_id_internal, this.version, this.sprache, this.sichtbar))
.then(res => {
this.content = res.data.content;
this.content_type = res.data.type;
document.querySelectorAll("#cms [data-confirm]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
BsConfirm.popup(el.dataset.confirm)
.then(() => {
Axios.get(el.href)
.then((res) => {
// TODO(chris): check for success then show message and/or reload
location = location;
})
.catch((err) => console.error("ERROR:", err));
})
.catch(() => {
});
});
});
document.querySelectorAll("#cms [data-href]").forEach((el) => {
el.href = el.dataset.href.replace(
/^ROOT\//,
FHC_JS_DATA_STORAGE_OBJECT.app_root
);
});
});
}
},
watch:{
sprache: function(sprache){
this.fetchContent();
},
'$route.params.content_id'(newVal) {
this.content_id_internal = newVal
this.fetchContent();
}
},
computed: {
sprache(){
return this.$p.user_language.value;
},
computeContentType: function () {
switch (this.content_type) {
case "raum_contentmittitel":
return "raum_contentmittitel";
case "news":
return "news_content";
default:
return "general";
};
},
},
created() {
this.fetchContent();
},
mounted() {
},
template: /*html*/ `
<!-- div that contains the content -->
<div id="fhc-cms-content" v-if="content">
<component ref="content" :is="computeContentType" :content="content" :content_id="content_id_internal" />
</div>
<p v-else>No content is available to display</p>
`,
};
@@ -0,0 +1,68 @@
import BsModal from "../../Bootstrap/Modal.js";
import RaumContent from "./Content_types/Raum_contentmittitel.js";
import ApiCms from '../../../api/factory/cms.js';
export default {
mixins:[BsModal],
components:{
BsModal,
RaumContent,
},
props:{
content_id:{
type: Number
},
ort_kurzbz:{
type: String
}
},
data(){
return{
result: false,
content: null,
};
},
methods:{
modalHidden: function(){
// reseting the content of the modal
this.content = null;
},
// this method is always called when the modal is shown
modalShown: function(){
if (this.content_id) {
this.$api
.call(ApiCms.content(this.content_id))
.then(res => {
this.content = res.data.content;
this.type = res.data.type;
});
}
},
},
mounted(){
this.modal = this.$refs.modalContainer;
},
template:/*html*/`
<bs-modal @hideBsModal="modalHidden" dialogClass="modal-xl" @showBsModal="modalShown" ref="modalContainer">
<template #title>
<span v-if="ort_kurzbz">{{ort_kurzbz}}</span>
<span v-else>Raum Informationen</span>
</template>
<template #default>
<RaumContent v-if="content" :content="content" :content_id="content_id"></RaumContent>
<div v-else>Der Content für diesen Raum konnte nicht geladen werden</div>
</template>
<template #footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</template>
</bs-modal>
`
};
@@ -0,0 +1,132 @@
import { replaceRelativeLegacyLink } from "../../../../helpers/LegacyLinkReplaceHelper.js"
export default {
name: "GeneralComponent",
props:{
content:{
type:String,
required:true,
},
},
methods: {
sanitizeLegacyTables(table) {
// find nested tables and replace with p element
const tt = table.querySelectorAll('table')
tt.forEach(t => {
const textContent = t.textContent.trim();
const pElement = document.createElement('p');
pElement.textContent = textContent;
t.parentNode.replaceChild(pElement, t);
})
// find unordered lists, traverse li childs and replace with p element -> more readable than 1 p tag for ul
const ul = table.querySelectorAll('ul')
ul.forEach(u => {
Array.from(u.children).forEach(li => {
const p = document.createElement('p');
p.textContent = li.textContent
u.parentNode.appendChild(p)
})
u.parentNode.removeChild(u)
})
// find bare text nodes and put into p element
const td = Array.from(table.querySelectorAll('td')).filter(el => el.scrollWidth > 100)
td.forEach(element => {
if (element.firstChild?.nodeType === Node.TEXT_NODE && element.firstChild.length > 10) {
const p = document.createElement('p');
p.appendChild(element.firstChild)
element.appendChild(p);
}
});
// flatten nested th elements
const ths = Array.from(table.querySelectorAll('th'))
ths.forEach(th => {
if(th.children.length > 1) {
th.innerHTML = Array.from(th.childNodes).find(cn => cn.textContent).textContent
}
})
// let p elements wrap on overflow
const p = table.querySelectorAll('p')
p.forEach(p => {
p.style.setProperty('word-wrap', 'break-word');
p.style.setProperty('white-space', 'normal');
p.style.setProperty('max-width', '400px');
})
},
prepareContent() {
// replaces the tablesorter with the tabulator
let tables = Array.from(document.getElementsByClassName("tablesorter"));
tables.forEach((table, index) => {
this.sanitizeLegacyTables(table)
new Tabulator(table, {
index: index,
layout: "fitDataFill",
columnDefaults: {
formatter: "html",
resizable: true,
minWidth: "100px"
}
})
})
document.querySelectorAll("#cms [data-confirm]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
BsConfirm.popup(el.dataset.confirm)
.then(() => {
Axios.get(el.href)
.then((res) => {
// TODO(chris): check for success then show message and/or reload
location = location;
})
.catch((err) => console.error("ERROR:", err));
})
.catch(() => {});
});
});
document.querySelectorAll("#cms [data-href]").forEach((el) => {
el.href = el.dataset.href.replace(
/^ROOT\//,
FHC_JS_DATA_STORAGE_OBJECT.app_root
);
});
document.querySelectorAll("[href]").forEach((element) => {
let orignal_href = element.getAttribute("href");
let new_href = replaceRelativeLegacyLink(orignal_href);
element.href = new_href;
});
document.querySelectorAll("[style*=background-color]").forEach((element) => {
if (element.style.backgroundColor == "rgb(255, 255, 255)"){
element.style.backgroundColor = "var(--fhc-background)";
}
if(element.querySelector("*[style*=background-color]")){
element.style.backgroundColor = "var(--fhc-tertiary)";
}
});
}
},
updated() {
this.prepareContent();
},
mounted(){
this.prepareContent();
},
template: /*html*/ `
<!-- div that contains the content -->
<div v-if="content" class="container" style="max-width: 100%;"><div class="row"><div class="col">
<div v-html="content" ></div>
</div></div></div>
<p v-else>Content was not found</p>
`,
};
@@ -0,0 +1,142 @@
import { replaceRelativeLegacyLink } from "../../../../helpers/LegacyLinkReplaceHelper.js"
export default {
name: "NewsContentType",
props:{
content:{
type:String,
required:true,
},
},
methods: {
sanitizeLegacyTables(table) {
// find nested tables and replace with p element
const tt = table.querySelectorAll('table')
tt.forEach(t => {
const textContent = t.textContent.trim();
const pElement = document.createElement('p');
pElement.textContent = textContent;
t.parentNode.replaceChild(pElement, t);
})
// find unordered lists, traverse li childs and replace with p element -> more readable than 1 p tag for ul
const ul = table.querySelectorAll('ul')
ul.forEach(u => {
Array.from(u.children).forEach(li => {
const p = document.createElement('p');
p.textContent = li.textContent
u.parentNode.appendChild(p)
})
u.parentNode.removeChild(u)
})
// find bare text nodes and put into p element
const td = Array.from(table.querySelectorAll('td')).filter(el => el.scrollWidth > 100)
td.forEach(element => {
if (element.firstChild?.nodeType === Node.TEXT_NODE && element.firstChild.length > 10) {
const p = document.createElement('p');
p.appendChild(element.firstChild)
element.appendChild(p);
}
});
// flatten nested th elements
const ths = Array.from(table.querySelectorAll('th'))
ths.forEach(th => {
if(th.children.length > 1) {
th.innerHTML = Array.from(th.childNodes).find(cn => cn.textContent).textContent
}
})
// let p elements wrap on overflow
const p = table.querySelectorAll('p')
p.forEach(p => {
p.style.setProperty('word-wrap', 'break-word');
p.style.setProperty('white-space', 'normal');
p.style.setProperty('max-width', '400px');
})
}
},
mounted(){
// replaces the tablesorter with the tabulator
let tables = Array.from(document.getElementsByClassName("tablesorter"));
tables.forEach((table, index) => {
this.sanitizeLegacyTables(table)
new Tabulator(table, {
index: index,
layout: "fitDataFill",
columnDefaults: {
formatter: "html",
resizable: true,
minWidth: "100px"
}
})
})
document.querySelectorAll("#cms [data-confirm]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
BsConfirm.popup(el.dataset.confirm)
.then(() => {
Axios.get(el.href)
.then((res) => {
// TODO(chris): check for success then show message and/or reload
location = location;
})
.catch((err) => console.error("ERROR:", err));
})
.catch(() => {});
});
});
document.querySelectorAll("#cms [data-href]").forEach((el) => {
el.href = el.dataset.href.replace(
/^ROOT\//,
FHC_JS_DATA_STORAGE_OBJECT.app_root
);
});
document.querySelectorAll("[href]").forEach((element) => {
let orignal_href = element.getAttribute("href");
let new_href = replaceRelativeLegacyLink(orignal_href);
element.href = new_href;
});
document.querySelectorAll("[style*=background-color]").forEach((element) => {
if (element.style.backgroundColor == "rgb(255, 255, 255)"){
element.style.backgroundColor = "var(--fhc-background)";
}
if(element.querySelector("*[style*=background-color]")){
element.style.backgroundColor = "var(--fhc-tertiary)";
}
});
Vue.nextTick(() => {
document.querySelectorAll(".card-header").forEach((el) => {
el.classList.add("fhc-primary");
});
document.querySelectorAll(".row").forEach((el) => {
el.classList.add("w-100");
el.classList.add("align-items-center");
});
document.querySelectorAll(".row h2").forEach((el) => {
el.classList.add("mb-0");
});
})
},
template: /*html*/ `
<!-- div that contains the content -->
<div v-if="content" class="container" style="max-width: 100%;"><div class="row"><div class="col">
<div v-html="content" ></div>
</div></div></div>
<p v-else>Content was not found</p>
`,
};
@@ -0,0 +1,137 @@
export default {
name: "RaumComponent",
data() {
return {
imgContent: null
}
},
props:{
content:{
type:String,
required:true,
},
content_id:{
type: [Number, String],
}
},
methods: {
sanitizeLegacyTables(table) {
// find nested tables and replace with p element
const tt = table.querySelectorAll('table')
tt.forEach(t => {
const textContent = t.textContent.trim();
const pElement = document.createElement('p');
pElement.textContent = textContent;
t.parentNode.replaceChild(pElement, t);
})
// find unordered lists, traverse li childs and replace with p element -> more readable than 1 p tag for ul
const ul = table.querySelectorAll('ul')
ul.forEach(u => {
Array.from(u.children).forEach(li => {
const p = document.createElement('p');
p.textContent = li.textContent
u.parentNode.appendChild(p)
})
u.parentNode.removeChild(u)
})
// find bare text nodes and put into p element
const td = Array.from(table.querySelectorAll('td')).filter(el => el.scrollWidth > 100)
td.forEach(element => {
if (element.firstChild?.nodeType === Node.TEXT_NODE && element.firstChild.length > 10) {
const p = document.createElement('p');
p.appendChild(element.firstChild)
element.appendChild(p);
}
});
// flatten nested th elements
const ths = Array.from(table.querySelectorAll('th'))
ths.forEach(th => {
if(th.children.length > 1) {
th.innerHTML = Array.from(th.childNodes).find(cn => cn.textContent).textContent
}
})
// let p elements wrap on overflow
const p = table.querySelectorAll('p')
p.forEach(p => {
p.style.setProperty('word-wrap', 'break-word');
p.style.setProperty('white-space', 'normal');
p.style.setProperty('max-width', '400px');
})
}
},
mounted(){
// replaces the tablesorter with the tabulator
let tables = document.getElementsByClassName("tablesorter");
for (let table of tables) {
this.sanitizeLegacyTables(table)
new Tabulator(table, {
layout: "fitDataStretch",
columnDefaults: {
formatter: "html",
resizable: false,
minWidth: "100px",
}
})
}
let title = document.getElementsByTagName("h1");
title = title.length ? title[0] : null;
// tries to wrap the Raum titel with a link tag that redirects to the Reservierungen of that Raum
if (title && title.innerText)
{
let room_name = title.innerText;
let room_name_reg_exp = new RegExp("\\w*\\s([a-zA-Z][0-9\\.]+)$");
let room_name_reg_exp_result = room_name.match(room_name_reg_exp);
if(room_name_reg_exp_result)
{
room_name = room_name_reg_exp_result[0];
room_name = room_name.replace(" ","_");
let link_element = document.createElement("a");
link_element.href = FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + "/CisVue/Cms/getRoomInformation/" + room_name;
link_element.appendChild(title.cloneNode(true));
title.replaceWith(link_element);
}
else
{
console.error(`the regular expression did not match the room name: ${room_name}`);
}
return
}
const parser = new DOMParser()
const doc = parser.parseFromString(`<div>${this.content}</div>`, "text/html");
const img = doc.querySelector("img")
if(img && img.title)
{
const imgAttributes = {}
for (let attr of img.attributes) {
imgAttributes[attr.name] = attr.value
}
this.imgContent = imgAttributes
}
console.error(`was not able to get the title of the raum_contentmittitel`);
},
template: /*html*/ `
<!-- div that contains the content -->
<!-- TODO: test with more img content from cms-->
<div v-if="imgContent"><img v-bind="imgContent"/></div>
<div v-html="content" v-else-if="content" ></div>
<p v-else>Content was not found</p>
`,
};
+116
View File
@@ -0,0 +1,116 @@
import Pagination from "../../Pagination/Pagination.js";
import StudiengangInformation from "./StudiengangInformation/StudiengangInformation.js";
import BsConfirm from "../../Bootstrap/Confirm.js";
import ApiCms from '../../../api/factory/cms.js';
export default {
name: "NewsComponent",
components: {
Pagination,
StudiengangInformation,
},
data() {
return {
content: null,
maxPageCount: 0,
page_size: 10,
page:1,
};
},
watch:{
'$p.user_language.value':function(sprache){
this.fetchNews();
}
},
computed:{
sprache: function(){
return this.$p.user_language.value;
},
},
methods: {
fetchNews() {
return this.$api
.call(ApiCms.getNews(this.page, this.page_size, this.sprache))
.then(res => res.data)
.then(result => {
this.content = result;
document.querySelectorAll("#cms [data-confirm]").forEach((el) => {
el.addEventListener("click", (evt) => {
evt.preventDefault();
BsConfirm.popup(el.dataset.confirm)
.then(() => {
Axios.get(el.href)
.then((res) => {
// TODO(chris): check for success then show message and/or reload
location = location;
})
.catch((err) => console.error("ERROR:", err));
})
.catch(() => {
});
});
});
document.querySelectorAll("#cms [data-href]").forEach((el) => {
el.href = el.dataset.href.replace(
/^ROOT\//,
FHC_JS_DATA_STORAGE_OBJECT.app_root
);
});
Vue.nextTick(()=>{
document.querySelectorAll(".card-header").forEach((el) => {
el.classList.add("fhc-primary");
});
document.querySelectorAll(".row").forEach((el) => {
el.classList.add("w-100");
el.classList.add("align-items-center");
});
document.querySelectorAll(".row h2").forEach((el) => {
el.classList.add("mb-0");
});
})
});
},
loadNewPageContent(data) {
this.$api
.call(ApiCms.getNews(data.page, data.rows))
.then(res => res.data)
.then(result => {
this.content = result;
});
}
},
created() {
this.fetchNews();
this.$api
.call(ApiCms.getNewsRowCount())
.then(res => res.data)
.then(result => {
this.maxPageCount = result;
});
},
template: /*html*/ `
<h2 class="fhc-primary-color">News</h2>
<hr/>
<pagination v-show="content?true:false" :page_size="page_size" @page="page=$event.page; loadNewPageContent($event)" :maxPageCount="maxPageCount">
</pagination>
<div class="container-fluid mt-4">
<div class="row">
<div class="col" v-html="content">
</div>
<div class="col-auto">
<div style="width:15rem">
<studiengang-information></studiengang-information>
</div>
</div>
</div>
</div>
<pagination v-show="content?true:false" :page_size="page_size" @page="loadNewPageContent" :maxPageCount="maxPageCount">
</pagination>
`,
};
@@ -0,0 +1,122 @@
import StudiengangPerson from "./StudiengangPerson.js";
import StudiengangVertretung from "./StudiengangVertretung.js";
import ApiStudiengang from '../../../../api/factory/studiengang.js';
export default {
data(){
return{
studiengang:null,
semester: null,
stg_ltg: null,
gf_ltg: null,
stv_ltg: null,
ass: null,
hochschulvertr: null,
stdv: null,
jahrgangsvertr: null,
}
},
props:{
displayWidget:{
type:Boolean,
default:false,
}
},
components:{
StudiengangPerson,
StudiengangVertretung,
},
template:/*html*/`
<div id="fhc-studiengang-informationen">
<template v-if="studiengang?.bezeichnung && semester">
<div class="card card-body mb-3 border-0">
<div class="mb-1">
<h2 class="h4 mb-1 pb-0">{{$p.t('lehre','studiengang')}}:</h2>
<span class="mb-1">{{studiengang?.bezeichnung}}</span>
</div>
<div class="mb-1">
<h2 class="h4 mb-1 pb-0">Moodle:</h2>
<a class="fhc-link-color mb-1" target="_blank" :href="moodleLink">{{studiengang?.kurzbzlang}}</a>
</div>
<div :class="{'mb-1':studiengang?.zusatzinfo_html}">
<h2 class="h4 mb-1 pb-0">{{$p.t('lehre','studiensemester')}}: </h2>
<span class="mb-1">{{semester}}</span>
</div>
<div class="zusatzinfo" v-if="studiengang?.zusatzinfo_html" v-html="studiengang?.zusatzinfo_html"></div>
</div>
</template>
<template v-for="{title, collection} in collection_array">
<template v-if="Array.isArray(collection) && collection.length !==0">
<h2 class="h5 text-truncate">{{title}}</h2>
<template v-if="displayWidget">
<div class="d-flex flex-wrap flex-row mb-3 gap-2">
<template v-for="person in collection">
<studiengang-person displayWidget v-bind="person"></studiengang-person>
</template>
</div>
</template>
<template v-else>
<template v-for="person in collection">
<div class="mb-3">
<studiengang-person v-bind="person"></studiengang-person>
</div>
</template>
</template>
</template>
</template>
<template v-if="hochschulvertr && Array.isArray(hochschulvertr) && hochschulvertr.length >0">
<studiengang-vertretung showBezeichnung :title="$p.t('studiengangInformation', 'Hochschulvertretung')" :vertretungsList="hochschulvertr"></studiengang-vertretung>
</template>
<template v-if="stdv && Array.isArray(stdv) && stdv.length >0">
<studiengang-vertretung :title="$p.t('studiengangInformation', 'Studienvertretung').concat(studiengang.kurzbzlang??'')" :vertretungsList="stdv"></studiengang-vertretung>
</template>
<template v-if="jahrgangsvertr && Array.isArray(jahrgangsvertr) && jahrgangsvertr.length >0">
<studiengang-vertretung :title="$p.t('studiengangInformation', 'Jahrgangsvertretung')" :vertretungsList="jahrgangsvertr"></studiengang-vertretung>
</template>
</div>
`,
computed:{
// this function concatenates the studiengangsleitung and the assistenz or the
// geschaeftsfuehrende-Stellvertretende Leitung if both collections only contain one person
collection_array: function(){
let returnData = [];
if (Array.isArray(this.stg_ltg) && this.stg_ltg.length == 1 && Array.isArray(this.ass) && this.ass.length == 1)
{
returnData.push({ title: `${this.$p.t('global', 'studiengangsleitung')}/${this.$p.t('studiengangInformation', 'assistenz')}` , collection: [...this.stg_ltg, ...this.ass] });
}
else
{
returnData.push({ title: this.$p.t('global', 'studiengangsleitung'), collection: this.stg_ltg });
returnData.push({ title: this.$p.t('studiengangInformation', 'assistenz'), collection: this.ass });
}
if (Array.isArray(this.gf_ltg) && this.gf_ltg.length == 1 && Array.isArray(this.stv_ltg) && this.stv_ltg.length == 1)
{
returnData.push({ title: this.$p.t('studiengangInformation', 'geschaeftsfuehrende_stellvertretende_leitung'), collection: [...this.gf_ltg, ...this.stv_ltg] });
}
else
{
returnData.push({ title: this.$p.t('studiengangInformation', 'geschaeftsfuehrende_leitung'), collection: this.gf_ltg });
returnData.push({ title: this.$p.t('studiengangInformation', 'stellvertretende_leitung'), collection: this.stv_ltg });
}
return returnData;
},
moodleLink: function(){
// early return if the studiengang information is not available
if(!this.studiengang || !this.studiengang.studiengang_kz) return;
return `https://moodle.technikum-wien.at/course/view.php?idnumber=dl` + this.studiengang.studiengang_kz;
},
},
mounted() {
this.$api
.call(ApiStudiengang.studiengangInformation())
.then(res => res.data)
.then(studiengangInformationen => {
Object.assign(this, studiengangInformationen);
});
}
};
@@ -0,0 +1,93 @@
export default {
props:{
uid:String,
vorname:String,
nachname:String,
titelpre:String,
kontakt:String,
telefoneklappe:String,
email:String,
planbezeichnung:String,
foto:String,
displayWidget:{
type:Boolean,
default:false,
}
},
template:/*html*/`
<div class="card border-0" :style="{'width':displayWidget?'12rem':'15rem'}">
<div class="d-flex justify-content-center">
<img :src="base64Image" alt="mitarbeiter_foto" style="width: 110px; height: auto; object-fir:scale-down;" class="card-img-top" >
</div>
<div class="card-body p-2 flex-grow-0" style="min-height: 50px;">
<h6 class="text-center card-title mb-0">{{fullname}} <a v-if="profilViewLink" :href="profilViewLink" :aria-label="$p.t('profil','profil')" :title="$p.t('profil','profil')"><i class="ms-2 fa fa-arrow-up-right-from-square fhc-primary-color" aria-hidden="true"></i></a></h6>
</div>
<hr class="my-0">
<div class="card-body p-2">
<dl class="stgkontaktinfo">
<dt><i class="fa fa-phone me-2"></i></dt>
<dd class="mb-3"><a class="fhc-link-color" :href="phone.link">{{phone.number}}</a></dd>
<dt><i class="fa fa-home me-2"></i></dt>
<dd class="mb-3">{{ort}}</dd>
<dt><i class="fa-regular fa-envelope me-2"></i></dt>
<dd class="mb-3"><a class="fhc-link-color" :href="email_link" v-html="formattedEmail"></a></dd>
</dl>
</div>
</div>
`,
computed:{
formattedEmail: function(){
if(!this.email ) return null;
let emailString= this.email.replace("mailto:", "");
// when splitting a string, the letter that is used to split the string will be removed from the result
let emailArray = emailString.split('@');
// returns both parts of the splitted string in combination with the removed letter and a word break
return emailArray[0] + '@<wbr>' + emailArray[1];
},
fullname: function () {
if (this.titelpre && this.vorname && this.nachname) {
return `${this.titelpre} ${this.vorname} ${this.nachname}`;
}
else if (this.vorname && this.nachname) {
return `${this.vorname} ${this.nachname}`;
}
else if (this.nachname) {
return this.vorname;
}
else {
return null;
}
},
phone: function () {
if (this.kontakt && this.telefoneklappe) {
return {
link: "tel:".concat(this.kontakt).concat(" " + this.telefoneklappe),
number: this.kontakt.concat(" " + this.telefoneklappe),
}
}
else {
return this.kontakt ? {
link: "tel:".concat(this.kontakt),
number: this.kontakt,
} : null;
}
},
email_link: function () {
return this.email ? "mailto:".concat(this.email) : null;
},
base64Image:function(){
return this.foto ? 'data:image/png;base64,'.concat(this.foto) : null;
},
ort:function(){
return this.planbezeichnung ?? null;
},
profilViewLink: function(){
return this.uid ? FHC_JS_DATA_STORAGE_OBJECT.app_root.concat(FHC_JS_DATA_STORAGE_OBJECT.ci_router).concat("/Cis/Profil/View/").concat(this.uid): null;
},
},
}
@@ -0,0 +1,32 @@
export default {
props: {
title:String,
vertretungsList:Array,
showBezeichnung:Boolean,
},
template:/*html*/`
<div class="card mb-3 border-0">
<div class="card-header">
<span>{{title}}</span>
</div>
<div class="card-body">
<p v-for="vertretung in vertretungsList">
<a v-if="profilViewLink(vertretung.uid)" :href="profilViewLink(vertretung.uid)" :aria-label="$p.t('profil','profil')" :title="$p.t('profil','profil')">
<i class="me-2 fa fa-arrow-up-right-from-square fhc-primary-color" aria-hidden="true"></i>
</a>
{{vertretungFormatedName(vertretung,false)}}
</p>
</div>
</div>
`,
methods: {
profilViewLink: function (uid) {
return uid ? FHC_JS_DATA_STORAGE_OBJECT.app_root.concat(FHC_JS_DATA_STORAGE_OBJECT.ci_router).concat("/Cis/Profil/View/").concat(uid) : null;
},
vertretungFormatedName: function (vertretung) {
if (!vertretung) return null;
return `${vertretung.vorname ?? ''} ${vertretung.nachname ?? ''} ${vertretung.bezeichnung && this.showBezeichnung ? '('.concat(vertretung.bezeichnung.replace("(", "").replace(")", "")).concat(")") : ''}`
},
},
}
@@ -0,0 +1,107 @@
import FhcCalendar from "../../Calendar/LvPlan.js";
import ApiLvPlan from '../../../api/factory/lvPlan.js';
import ApiAuthinfo from '../../../api/factory/authinfo.js';
export const DEFAULT_MODE_LVPLAN = 'Week'
export default {
name: 'LvPlanLehrveranstaltung',
components: {
FhcCalendar
},
props: {
viewData: Object, // NOTE(chris): this is inherited from router-view
propsViewData: Object
},
data() {
return {
lv: null
};
},
computed:{
currentDay() {
if (!this.propsViewData?.focus_date || isNaN(new Date(this.propsViewData?.focus_date)))
return luxon.DateTime.now().setZone(this.viewData.timezone).toISODate();
return this.propsViewData?.focus_date;
},
currentMode() {
if (!this.propsViewData?.mode || !['day', 'week', 'month'].includes(this.propsViewData?.mode.toLowerCase()))
return DEFAULT_MODE_LVPLAN;
return this.propsViewData?.mode;
},
currentLv() {
if (isNaN(parseInt(this.propsViewData?.lv_id)))
return null;
return this.propsViewData.lv_id;
},
lvTitle() {
if (this.currentLv === null)
return '';
if (!this.lv)
return '';
if (this.$p.user_language.value === 'English')
return this.lv.bezeichnung_english;
return this.lv.bezeichnung;
}
},
methods: {
handleChangeDate(day, newMode) {
return this.handleChangeMode(newMode, day);
},
handleChangeMode(newMode, day) {
const mode = newMode[0].toUpperCase() + newMode.slice(1)
const focus_date = day.toISODate();
this.$router.push({
name: "LvPlan",
params: {
mode,
focus_date,
lv_id: this.currentLv
}
});
},
getPromiseFunc(start, end) {
return [
this.$api.call(ApiLvPlan.eventsLv(this.propsViewData.lv_id, start.toISODate(), end.toISODate())),
this.$api.call(ApiLvPlan.getLvPlanReservierungen(start.toISODate(), end.toISODate()))
];
}
},
created() {
if (this.currentLv === null)
return;
this.$api
.call(ApiLvPlan.getLv(this.propsViewData?.lv_id))
.then(res => {
this.lv = res.data;
});
},
template: /*html*/`
<div class="cis-lvplan-personal d-flex flex-column h-100">
<h2>
{{ $p.t('lehre/stundenplan') }}
<span v-if="lvTitle" class="ps-3">
{{ lvTitle }}
</span>
</h2>
<hr>
<div v-if="currentLv === null || lv === false">
{{ $p.t('lehre/noLvFound') }}
</div>
<fhc-calendar
v-else-if="lv"
ref="calendar"
:timezone="viewData.timezone"
:get-promise-func="getPromiseFunc"
:date="currentDay"
:mode="currentMode"
@update:date="handleChangeDate"
@update:mode="handleChangeMode"
class="responsive-calendar"
/>
</div>`
};
+146
View File
@@ -0,0 +1,146 @@
import FhcCalendar from "../../Calendar/LvPlan.js";
import ApiLvPlan from '../../../api/factory/lvPlan.js';
import ApiAuthinfo from '../../../api/factory/authinfo.js';
export const DEFAULT_MODE_LVPLAN = 'Week'
export default {
name: 'LvPlan',
components: {
FhcCalendar
},
props: {
viewData: Object, // NOTE(chris): this is inherited from router-view
propsViewData: Object
},
data() {
const now = luxon.DateTime.now().setZone(this.viewData.timezone);
return {
studiensemester_kurzbz: null,
studiensemester_start: null,
studiensemester_ende: null,
uid: null,
lv: null
};
},
computed:{
currentDay() {
return this.propsViewData?.focus_date || luxon.DateTime.now().setZone(this.viewData.timezone).toISODate();
},
currentMode() {
return this.propsViewData?.mode || DEFAULT_MODE_LVPLAN;
},
downloadLinks() {
if (!this.studiensemester_start || !this.studiensemester_ende || !this.uid)
return false;
const opts = { zone: this.viewData.timezone };
const start = luxon.DateTime
.fromISO(this.studiensemester_start, opts)
.toUnixInteger();
const ende = luxon.DateTime
.fromISO(this.studiensemester_ende, opts)
.toUnixInteger();
const download_link = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ 'cis/private/lvplan/stpl_kalender.php'
+ '?type=student'
+ '&pers_uid=' + this.uid
+ '&begin=' + start
+ '&ende=' + ende;
return [
{ title: "excel", icon: 'fa-solid fa-file-excel', link: download_link + '&format=excel' },
{ title: "csv", icon: 'fa-solid fa-file-csv', link: download_link + '&format=csv' },
{ title: "ical1", icon: 'fa-regular fa-calendar', link: download_link + '&format=ical&version=1&target=ical' },
{ title: "ical2", icon: 'fa-regular fa-calendar', link: download_link + '&format=ical&version=2&target=ical' }
];
}
},
methods: {
handleChangeDate(day, newMode) {
return this.handleChangeMode(newMode, day);
},
handleChangeMode(newMode, day) {
const mode = newMode[0].toUpperCase() + newMode.slice(1)
const focus_date = day.toISODate();
this.$router.push({
name: "LvPlan",
params: {
mode,
focus_date,
lv_id: this.propsViewData?.lv_id ?? null
}
});
},
updateRange(rangeInterval) {
this.$api
.call(ApiLvPlan.studiensemesterDateInterval(
rangeInterval.end.startOf('week').toISODate()
))
.then(res => {
this.studiensemester_kurzbz = res.data.studiensemester_kurzbz;
this.studiensemester_start = res.data.start;
this.studiensemester_ende = res.data.ende;
});
},
getPromiseFunc(start, end) {
return [
this.$api.call(ApiLvPlan.LvPlanEvents(start.toISODate(), end.toISODate(), this.propsViewData.lv_id)),
this.$api.call(ApiLvPlan.getLvPlanReservierungen(start.toISODate(), end.toISODate()))
];
}
},
created() {
this.$api
.call(ApiAuthinfo.getAuthUID())
.then(res => {
this.uid = res.data.uid;
});
},
template: /*html*/`
<div class="cis-lvplan-personal d-flex flex-column h-100">
<h2>
{{ $p.t('lehre/stundenplan') }}
<span style="padding-left: 0.4em;" v-show="studiensemester_kurzbz">
{{ studiensemester_kurzbz }}
</span>
<span style="padding-left: 0.5em;" v-show="propsViewData?.lv_id && lv">
{{ $p.user_language.value === 'German' ? lv?.bezeichnung : lv?.bezeichnung_english }}
</span>
</h2>
<hr>
<fhc-calendar
ref="calendar"
v-model:lv="lv"
:timezone="viewData.timezone"
:get-promise-func="getPromiseFunc"
:date="currentDay"
:mode="currentMode"
@update:date="handleChangeDate"
@update:mode="handleChangeMode"
@update:range="updateRange"
class="responsive-calendar"
>
<div
v-if="downloadLinks"
class="d-flex gap-1 justify-items-start"
>
<div v-for="{ title, icon, link } in downloadLinks">
<a
:href="link"
:aria-label="title"
class="py-1 btn btn-outline-secondary"
>
<div class="d-flex flex-column">
<i aria-hidden="true" :class="icon"></i>
<span style="font-size:.5rem">{{ title }}</span>
</div>
</a>
</div>
</div>
</fhc-calendar>
</div>`
};
+155
View File
@@ -0,0 +1,155 @@
import FhcCalendar from "../../Calendar/LvPlan.js";
import ApiLvPlan from '../../../api/factory/lvPlan.js';
import ApiAuthinfo from '../../../api/factory/authinfo.js';
export const DEFAULT_MODE_LVPLAN = 'Week'
export default {
name: 'LvPlanPersonal',
components: {
FhcCalendar
},
props: {
viewData: Object, // NOTE(chris): this is inherited from router-view
propsViewData: Object
},
data() {
return {
studiensemester_kurzbz: null,
studiensemester_start: null,
studiensemester_ende: null,
uid: null,
isMitarbeiter: false,
isStudent: false
};
},
computed:{
currentDay() {
if (!this.propsViewData?.focus_date || isNaN(new Date(this.propsViewData?.focus_date)))
return luxon.DateTime.now().setZone(this.viewData.timezone).toISODate();
return this.propsViewData?.focus_date;
},
currentMode() {
if (!this.propsViewData?.mode || !['day', 'week', 'month'].includes(this.propsViewData?.mode.toLowerCase()))
return DEFAULT_MODE_LVPLAN;
return this.propsViewData?.mode;
},
downloadLinks() {
if (!this.studiensemester_start || !this.studiensemester_ende || !this.uid)
return false;
let type = false;
type = this.isStudent ? 'student' : type;
type = this.isMitarbeiter ? 'lektor' : type;
if (false === type)
{
return;
}
const opts = { zone: this.viewData.timezone };
const start = luxon.DateTime
.fromISO(this.studiensemester_start, opts)
.toUnixInteger();
const ende = luxon.DateTime
.fromISO(this.studiensemester_ende, opts)
.toUnixInteger();
const download_link = FHC_JS_DATA_STORAGE_OBJECT.app_root
+ 'cis/private/lvplan/stpl_kalender.php'
+ '?type=' + type
+ '&pers_uid=' + this.uid
+ '&begin=' + start
+ '&ende=' + ende;
return [
{ title: "excel", icon: 'fa-solid fa-file-excel', link: download_link + '&format=excel' },
{ title: "csv", icon: 'fa-solid fa-file-csv', link: download_link + '&format=csv' },
{ title: "ical1", icon: 'fa-regular fa-calendar', link: download_link + '&format=ical&version=1&target=ical' },
{ title: "ical2", icon: 'fa-regular fa-calendar', link: download_link + '&format=ical&version=2&target=ical' }
];
}
},
methods: {
handleChangeDate(day, newMode) {
return this.handleChangeMode(newMode, day);
},
handleChangeMode(newMode, day) {
const mode = newMode[0].toUpperCase() + newMode.slice(1)
const focus_date = day.toISODate();
this.$router.push({
name: "MyLvPlan",
params: {
mode,
focus_date
}
});
},
updateRange(rangeInterval) {
this.$api
.call(ApiLvPlan.studiensemesterDateInterval(
rangeInterval.end.startOf('week').toISODate()
))
.then(res => {
this.studiensemester_kurzbz = res.data.studiensemester_kurzbz;
this.studiensemester_start = res.data.start;
this.studiensemester_ende = res.data.ende;
});
},
getPromiseFunc(start, end) {
return [
this.$api.call(ApiLvPlan.eventsPersonal(start.toISODate(), end.toISODate())),
this.$api.call(ApiLvPlan.getLvPlanReservierungen(start.toISODate(), end.toISODate()))
];
}
},
created() {
this.$api
.call(ApiAuthinfo.getAuthInfo())
.then(res => {
this.uid = res.data.uid;
this.isMitarbeiter = res.data.isMitarbeiter;
this.isStudent = res.data.isStudent;
});
},
template: /*html*/`
<div class="cis-lvplan-personal d-flex flex-column h-100">
<h2>
{{ $p.t('lehre/stundenplan') }}
<span v-if="studiensemester_kurzbz" class="ps-3">
{{ studiensemester_kurzbz }}
</span>
</h2>
<hr>
<fhc-calendar
ref="calendar"
:timezone="viewData.timezone"
:get-promise-func="getPromiseFunc"
:date="currentDay"
:mode="currentMode"
@update:date="handleChangeDate"
@update:mode="handleChangeMode"
@update:range="updateRange"
class="responsive-calendar"
>
<div
v-if="downloadLinks"
class="d-flex gap-1 justify-items-start"
>
<div v-for="{ title, icon, link } in downloadLinks">
<a
:href="link"
:aria-label="title"
class="py-1 btn btn-outline-secondary"
>
<div class="d-flex flex-column">
<i aria-hidden="true" :class="icon"></i>
<span style="font-size:.5rem">{{ title }}</span>
</div>
</a>
</div>
</div>
</fhc-calendar>
</div>`
};
+159
View File
@@ -0,0 +1,159 @@
import CisMenuEntry from "./Menu/Entry.js";
import FhcSearchbar from "../searchbar/searchbar.js";
import CisSprachen from "./Sprachen.js"
import ThemeSwitch from "./ThemeSwitch.js";
import ApiCisMenu from '../../api/factory/cis/menu.js';
export default {
components: {
CisMenuEntry,
FhcSearchbar,
CisSprachen,
ThemeSwitch,
},
props: {
rootUrl: String,
logoUrl: String,
avatarUrl: String,
logoutUrl: String,
selectedtypes: Array,
searchbaroptions: Object,
searchfunction: Function
},
data: () => {
return {
entries: [],
activeEntry:null,
url:null,
urlMatchRankings:[],
navUserDropdown:null,
menuOpen:true,
};
},
provide(){
return{
setActiveEntry: this.setActiveEntry,
addUrlCount: this.addUrlCount,
makeParentContentActive: this.makeParentContentActive,
}
},
computed:{
menuCollapseAriaLabel(){
if(this.menuOpen){
return this.$p.t('global', 'collapseMenu');
}else{
return this.$p.t('global', 'extendMenu');
}
},
highestMatchingUrlCount(){
// gets the hightest ranking inside the array
let highestMatch = Math.max(...this.urlMatchRankings);
if(this.urlMatchRankings.length > 0){
// if more than one entry has the same ranking, none should be active
return this.urlMatchRankings.filter((value)=>value == highestMatch).length > 1 ? null : highestMatch;
}
return null;
},
site_url(){
return FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router;
}
},
methods: {
fetchMenu() {
return this.$api
.call(ApiCisMenu.getMenu())
.then(res => res.data)
.then(menu => {
this.entries = menu;
});
},
checkSettingsVisibility: function (event) {
// hides the settings collapsible if the user clicks somewhere else
if (!this.$refs.navUserDropdown.contains(event.target)) {
this.navUserDropdown.hide();
}
},
handleShowNavUser(){
document.addEventListener("click", this.checkSettingsVisibility);
},
handleHideNavUser(){
document.removeEventListener("click", this.checkSettingsVisibility);
},
makeParentContentActive(content_id, collection=this.entries, parent=null){
if(!collection) return;
if (typeof collection == 'object' && !Array.isArray(collection) && Object.entries(collection).length > 0) {
collection = Object.values(collection);
}
for(let entry of collection){
if(entry.content_id == content_id){
this.activeEntry = parent;
}
this.makeParentContentActive(content_id, entry.childs, entry.content_id);
}
},
addUrlCount(count){
this.urlMatchRankings.push(count);
},
setActiveEntry(content_id){
this.activeEntry = content_id;
},
},
created(){
this.fetchMenu();
},
mounted(){
this.$p.loadCategory(['ui', 'global', 'profilUpdate'])
this.navUserDropdown = new bootstrap.Collapse(this.$refs.navUserDropdown,{
toggle: false
});
},
template: /*html*/`
<button id="nav-main-btn" class="navbar-toggler rounded-0" type="button" data-bs-toggle="offcanvas" data-bs-target="#nav-main" aria-controls="nav-main" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<fhc-searchbar ref="searchbar" id="nav-search" class="fhc-searchbar w-100 py-1 py-lg-2" :searchoptions="searchbaroptions" :searchfunction="searchfunction"></fhc-searchbar>
<div id="nav-logo" class="d-none d-lg-block">
<div class="d-flex h-100 justify-content-between">
<a :href="rootUrl">
<img :src="logoUrl" alt="Corporate Identity Logo">
</a>
<theme-switch></theme-switch>
</div>
</div>
<div id="nav-user">
<button id="nav-user-btn" class="btn btn-link rounded-0" type="button" data-bs-toggle="collapse" data-bs-target="#nav-user-menu" aria-expanded="false" aria-controls="nav-user-menu">
<img :src="avatarUrl" :alt="$p.t('profilUpdate/profilBild')" class="bg-dark avatar rounded-circle border border-dark"/>
</button>
<ul ref="navUserDropdown"
@[\`shown.bs.collapse\`]="handleShowNavUser"
@[\`hide.bs.collapse\`]="handleHideNavUser"
id="nav-user-menu" class="top-100 end-0 collapse list-unstyled" aria-labelledby="nav-user-btn">
<li><a class="fhc-dark-bg btn rounded-0 d-block" :href="site_url + '/Cis/Profil'" id="menu-profil">Profil</a></li>
<li >
<cis-sprachen @languageChanged="fetchMenu"></cis-sprachen>
</li>
<li><hr class="dropdown-divider m-0 "></li>
<li ><a class="fhc-dark-bg btn rounded-0 d-block" :href="logoutUrl">Logout</a></li>
</ul>
</div>
<nav id="nav-main" class="offcanvas offcanvas-start" tabindex="-1" aria-labelledby="nav-main-btn" data-bs-backdrop="false">
<div id="nav-main-sticky">
<div id="nav-main-toggle" class="position-static d-none d-lg-block ">
<button :aria-label="menuCollapseAriaLabel" type="button" @click="menuOpen = !menuOpen" class="btn text-light rounded-0 p-1 d-flex align-items-center" data-bs-toggle="collapse" data-bs-target=".nav-menu-collapse" aria-expanded="true" aria-controls="nav-sprachen nav-main-menu">
<i aria-hidden="true" class="fa fa-arrow-circle-left fhc-text"></i>
</button>
</div>
<div class="offcanvas-body p-0">
<div id="nav-main-menu" class="nav-menu-collapse collapse collapse-horizontal show">
<div>
<cis-menu-entry :highestMatchingUrlCount="highestMatchingUrlCount" :activeContent="activeEntry" v-for="entry in entries" :key="entry.content_id" :entry="entry" />
</div>
</div>
</div>
</div>
</nav>`
};
+241
View File
@@ -0,0 +1,241 @@
export default {
name: 'CisMenuEntry',
props: {
entry: Object,
level: {
type: Number,
default: 1
},
activeContent: [String, Number],
highestMatchingUrlCount: Number,
},
data: () => {
return {
collapse: null,
urlCount:0,
}
},
inject: ['makeParentContentActive', 'setActiveEntry','addUrlCount'],
watch:{
highestMatchingUrlCount: function(newValue)
{
// if this entry has the most matching url parts then it should be active
if (this.activeContent == null && newValue == this.urlCount)
{
this.setActiveEntry(this.entry.content_id);
}
},
activeContent: function(newValue){
if(newValue == this.entry.content_id){
// wenn der Menupunkt nicht bereits offen ist
if (!this.entry.menu_open){
this.entry.menu_open = true;
}
}else{
if (this.searchRecursiveChild(this.entry, 'content_id',newValue)) {
this.entry.menu_open = true;
} else {
this.entry.menu_open = false;
}
}
},
'entry.menu_open': function (newValue,oldValue) {
if (newValue)
{
// only invokes .show if this.collapse is not null
this.collapse && this.collapse.show();
}
else
{
// only invokes .hide if this.collapse is not null
this.collapse && this.collapse.hide();
if (this.activeContent == this.entry.content_id)
{
this.makeParentContentActive(this.entry.content_id);
}
}
},
},
computed: {
active: function () {
if (this.entry.menu_open){
return true;
}
else if (this.activeContent) {
return this.activeContent == this.entry.content_id;
} else {
return false;
}
},
link() {
if (this.entry.template_kurzbz == 'redirect') {
if (!this.entry.content)
return '';
let xmlDoc = (new DOMParser()).parseFromString(this.entry.content,"text/xml");
let url = xmlDoc.getElementsByTagName('url')[0];
if (!url)
return '';
// TODO(chris): replace get params
url = url.childNodes[0].nodeValue + "";
if (url.includes("../cms/news.php")) {
let news_regex = new RegExp("^\.\./cms/news\.php");
url = url.replace(news_regex, FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/CisVue/Cms/news');
}
else if (url.includes("../cms/content.php?")) {
let content_regex = new RegExp("^\.\./cms/content.php\\?content_id=([0-9]+)");
let content_regex_result = content_regex.exec(url);
// content_regex_result[1] will be the first matched group
return FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/CisVue/Cms/content/' + content_regex_result[1];
}
else if(url.includes("../index.ci.php")){
let index_regex = new RegExp("^\.\./index\.ci\.php");
url = url.replace(index_regex, FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router);
}
else if (url.includes("../")) {
let relative_regex = new RegExp("^\.\./");
url = url.replace(relative_regex, FHC_JS_DATA_STORAGE_OBJECT.app_root);
}
return url;
}
return FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/CisVue/Cms/content/' + this.entry.content_id;
},
hasFullLink() {
return this.link.startsWith(FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router)
},
target() {
if (this.entry.template_kurzbz == 'redirect') {
if (!this.entry.content)
return '';
let xmlDoc = (new DOMParser()).parseFromString(this.entry.content,"text/xml");
let target = xmlDoc.getElementsByTagName('target')[0];
if (!target)
return '';
target = target.childNodes[0].nodeValue + "";
if (target == 'content' || target == '_self')
target = "";
return target;
}
return ''
},
hasChilds() {
return this.entry.childs && this.entry.childs.length !== 0;
}
},
methods: {
getUrlMatchPoints(url,link){
let splitted_link = link.split('/');
let splitted_url = url.href.split('/');
let count = 0;
for(let part_url of splitted_url)
{
for (let part_link of splitted_link)
{
if(part_url == part_link)
{
count++;
}
}
}
this.urlCount = count;
this.addUrlCount(count);
},
checkActiveUrl(url){
this.getUrlMatchPoints(url,this.link);
let url_hash_spaceSymbol_regex = new RegExp("%20","gi");
let url_hash_sharpSymbol_regex = new RegExp("^#");
let url_hash = url.hash;
url_hash = url_hash.replace(url_hash_spaceSymbol_regex, " ").replace(url_hash_sharpSymbol_regex,"");
// if the url hash contains the titel of the menu
// or if the url equals the link of a menu
// then set the menu active
if (url_hash == this.entry.titel || url.href == this.link) {
this.setActiveEntry(this.entry.content_id);
}
},
// searches the childs of an entry recursively based on the value of a property
searchRecursiveChild(entry,property,value){
if (typeof entry.childs == 'object' && !Array.isArray(entry.childs) && Object.entries(entry.childs).length > 0){
entry.childs = Object.values(entry.childs);
}
for (let child of entry.childs) {
if (child[property] == value) {
return true;
}
if ((child.childs instanceof Array && child.childs.length > 0) || Object.values(child.childs).length > 0) {
if (this.searchRecursiveChild(child, property, value)){
return true;
}
}
}
return false;
},
toggleCollapse(evt) {
if (this.active)
{
this.makeParentContentActive(this.entry.content_id);
}
else
{
this.setActiveEntry(this.entry.content_id);
}
}
},
mounted() {
if (this.$refs.children) {
if (this.entry.menu_open)
this.$refs.children.className += ' show';
this.collapse = new bootstrap.Collapse(this.$refs.children, { toggle: false });
}
this.checkActiveUrl(new URL(window.location.href));
},
template: /*html*/`
<div v-if="entry.template_kurzbz == 'include'">
INCLUDE
</div>
<template v-else>
<template v-if="hasChilds">
<div class="btn-group w-100">
<a :target="target"
:href="(entry.menu_open && hasFullLink)?link:null"
@click="toggleCollapse"
:class="{
'btn btn-default rounded-0 text-start': true,
['btn-level-' + level]: true,
'fw-bold':active
}">
{{ entry.titel }}
</a>
<button @click.prevent="toggleCollapse" :aria-expanded="entry.menu_open"
:class="{
'btn btn-default rounded-0 dropdown-toggle dropdown-toggle-split flex-grow-0': true,
collapsed: !entry.menu_open
}">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
</div>
<ul ref="children"
class="nav w-100 collapse">
<cis-menu-entry :highestMatchingUrlCount="highestMatchingUrlCount" :activeContent="activeContent" v-for="child in entry.childs" :key="child" :entry="child" :level="level + 1"/>
</ul>
</template>
<a v-else
:href="link"
:target="target"
:class="{
'btn btn-default rounded-0 w-100 text-start': true,
['btn-level-' + level]: true,
'fw-bold':active
}"
@mouseup="setActiveEntry(entry.content_id)">
{{ entry.titel }}
</a>
</template>`
};
+113
View File
@@ -0,0 +1,113 @@
import { numberPadding, formatDate } from "../../../helpers/DateHelpers.js"
export default {
props: {
event: Object,
},
data() {
return {
}
},
computed: {
lektorenLinks: function () {
if (!this.event || !Array.isArray(this.event.lektor) || !this.event.lektor.length) return "a";
let lektorenLinks = {};
this.event.lektor.forEach((lektor) => {
lektorenLinks[lektor.kurzbz] = FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + `/Cis/Profil/View/${lektor.mitarbeiter_uid}`;
})
return lektorenLinks;
},
getOrtContentLink: function () {
if (!this.event || !this.event.ort_content_id) return "a";
return FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + `/CisVue/Cms/content/${this.event.ort_content_id}`
},
start_time: function () {
if (!this.event.start) return 'N/A';
if (!this.event.start instanceof Date) {
return this.event.start;
}
return numberPadding(this.event.start.getHours()) + ":" + numberPadding(this.event.start.getMinutes());
},
end_time: function () {
if (!this.event.end) return 'N/A';
if (!this.event.end instanceof Date) {
return this.event.end;
}
return numberPadding(this.event.end.getHours()) + ":" + numberPadding(this.event.end.getMinutes());
}
},
methods: {
mehtodNumberPadding: function (number) {
return numberPadding(number);
},
methodFormatDate: function (d) {
return formatDate(d);
},
},
template:/*html*/`
<table class="table table-hover mb-4">
<tbody>
<tr v-if="event?.datum">
<th>{{
$p.t('global','datum')?
$p.t('global','datum')+':'
:''
}}</th>
<td>{{methodFormatDate(event.datum)}}</td>
</tr>
<tr>
<th>{{
$p.t('global','raum')?
$p.t('global','raum')+':'
:''
}}</th>
<td>
<a v-if="event.ort_content_id" :href="getOrtContentLink"><i class="fa fa-arrow-up-right-from-square me-1 fhc-primary-color" ></i></a>
{{event.ort_kurzbz}}
</td>
</tr>
<tr>
<th>{{
$p.t('lehre','lehrveranstaltung')?
$p.t('lehre','lehrveranstaltung')+':'
:''
}}</th>
<td>{{'('+event.lehrform+') ' + event.lehrfach_bez}}</td>
</tr>
<tr>
<th>{{
$p.t('lehre','lektor')?
$p.t('lehre','lektor')+':'
:''
}}</th>
<td>
<div v-for="lektor in event.lektor" class="d-block">
<a v-if="lektorenLinks[lektor.kurzbz]" :href="lektorenLinks[lektor.kurzbz]"><i class="fa fa-arrow-up-right-from-square me-1 fhc-primary-color" ></i></a>
{{lektor.kurzbz}}
</div>
</td>
</tr>
<tr>
<th>{{
$p.t('ui','zeitraum')?
$p.t('ui','zeitraum')+':'
:''
}}</th>
<td>{{start_time + ' - ' + end_time}}</td>
</tr>
<tr>
<th>{{
$p.t('lehre','organisationseinheit')?
$p.t('lehre','organisationseinheit')+':'
:''
}}</th>
<td>{{event.organisationseinheit}}</td>
</tr>
</tbody>
</table>
`
}
+75
View File
@@ -0,0 +1,75 @@
export default {
props:{
menu:{
type:Array,
default:null,
},
containerStyles: Array,
rowStyles: Array,
hasLvPlanEintraege: {
required:false,
default:true,
type:Boolean,
},
},
data(){
return{
}
},
methods:{
c4_disabled: function (menuItem) {
if (!this.c4_link(menuItem) && !menuItem.c4_moodle_links?.length) {
return true;
}
if (menuItem.id == "addon_fhtw_menu_lvplan_lva" && !this.hasLvPlanEintraege){
return true;
}
return null;
},
c4_target: function (menuItem) {
if (menuItem.c4_moodle_links?.length > 0) return null;
return menuItem.c4_target ?? null;
},
c4_link(menuItem) {
if (!menuItem) return null;
if (Array.isArray(menuItem.c4_moodle_links) && menuItem.c4_moodle_links.length)
{
return null;
}
else
{
return menuItem.c4_link ?? null;
}
},
getMenuName(menuItem) {
if(menuItem.phrase) {
return this.$p.t(menuItem.phrase)
} else {
return menuItem.name
}
}
},
template:/*html*/`
<div v-if="!menu">{{$p.t('lehre','lehrveranstaltungsUnavailable')}}</div>
<div id="cis-menu" v-else>
<div class="container" :class="containerStyles">
<div class="row g-2 justify-content-center" :class="rowStyles">
<div style="min-height:150px; min-width:150px;" class="col-12 col-lg-6 col-xl-4" v-for="(menuItem, index) in menu" :key="index">
<a :id="menuItem.name" :class="{'dropdown-toggle':menuItem.c4_moodle_links?.length }" role="button" :href="c4_link(menuItem)"
:disabled="c4_disabled(menuItem)" :data-bs-toggle="menuItem.c4_moodle_links?.length?'dropdown':null"
class="menu-entry p-2 w-100 text-wrap border border-1 rounded-3 d-flex flex-column align-items-center justify-content-center text-center text-decoration-none link h-100">
<img :src="menuItem.c4_icon" :alt="menuItem.name" />
<p class="w-100 mt-2 mb-0">{{ getMenuName(menuItem) }}</p>
<a v-for="([text,link],index) in menuItem.c4_linkList" target="_blank" :href="link" class="my-1 w-100 submenu text-decoration-none" :index="index">{{text}}</a>
</a>
<ul v-if="menuItem.c4_moodle_links?.length" class="dropdown-menu p-0" :aria-labelledby="menuItem.name">
<li v-for="item in menuItem.c4_moodle_links"><a class="dropdown-item border-bottom" :href="item.url">{{item.lehrform}}</a></li>
</ul>
</div>
</div>
</div>
</div>
`
}
@@ -0,0 +1,117 @@
import BsModal from "../../Bootstrap/Modal.js";
import LvMenu from "./LvMenu.js";
import ApiAddons from '../../../api/factory/addons.js';
export default {
props:{
event:{
type:Object,
required:true,
default:null,
},
studiensemester: {
type: String,
required: false,
default: null,
},
titel: {
type: String,
required: false,
default: null,
},
// prop used to preselect a menu item and skip the grid overview
preselectedMenu: {
type: Object,
required: false,
default: null,
}
},
data(){
return {
result: false,
menu: [],
isMenuSelected:false,
hasLvPlanEintraege: true,
lvEvaluierungMessage: "",
}
},
mixins:[BsModal],
components:{
BsModal,
LvMenu,
},
inject: ["studium_studiensemester"],
methods:{
hiddenModal: function(){
this.isMenuSelected = false;
},
showModal: function(){
if (!this.preselectedMenu) {
this.$api
.call(ApiAddons.getLvMenu(this.event.lehrveranstaltung_id, (this.studiensemester ?? this.event.studiensemester_kurzbz)))
.then(res => {
if (res.data) {
this.menu = res.data;
}
});
} else {
this.isMenuSelected = true;
}
// check lv evaluierung info
if (this.studium_studiensemester) {
this.$fhcApi.factory.studium.getLvEvaluierungInfo(this.studium_studiensemester, this.event.lehreinheit_id ?? this.event.lehrveranstaltung_id)
.then(data => data.data)
.then(res => {
this.lvEvaluierungMessage = res.message;
})
}
// check if the lv has lvplan entries for this studiensemester
if (this.studiensemester && this.event) {
return this.$fhcApi.factory.studium.getLvPlanForStudiensemester(this.studiensemester, this.event.lehreinheit_id ?? this.event.lehrveranstaltung_id)
.then(data => data.data)
.then(res => {
if (Array.isArray(res) && res.length > 0) {
this.hasLvPlanEintraege = true;
} else {
this.hasLvPlanEintraege = false;
}
});
}
},
},
mounted(){
this.modal = this.$refs.modalContainer;
},
beforeUnmount(){
this.$refs.modalContainer.hide();
},
template:/*html*/`
<bs-modal :bodyClass="isMenuSelected ? '' : 'px-4 py-5'" @showBsModal="showModal" @hiddenBsModal="hiddenModal" ref="modalContainer" :dialogClass="{'modal-lg': !isMenuSelected, 'modal-fullscreen':isMenuSelected}">
<template #title>
<template v-if="titel">
<span>{{titel}}</span>
</template>
<template v-else>
<span v-if="event?.lehrfach_bez ">{{event?.lehrfach_bez + (event?.stg_kurzbzlang?' / ' + event?.stg_kurzbzlang:'')}}</span>
<span v-else>Lehrveranstaltungs Übersicht</span>
</template>
</template>
<template #default>
<div class="mb-4" v-if="lvEvaluierungMessage" v-html="lvEvaluierungMessage"></div>
<slot name="content"></slot>
<lv-menu v-model:isMenuSelected="isMenuSelected" :hasLvPlanEintraege="hasLvPlanEintraege" :preselectedMenu="preselectedMenu" :menu="menu" @hideModal="hide"></lv-menu>
</template>
</bs-modal>
`,
};
@@ -0,0 +1,63 @@
import FhcCalendar from "../../Calendar/LvPlan.js";
import ApiLvPlan from '../../../api/factory/lvPlan.js';
export const DEFAULT_MODE_RAUMINFO = 'Week'
export default {
name: "RoomInformation",
components: {
FhcCalendar
},
props:{
viewData: Object, // NOTE(chris): this is inherited from router-view
propsViewData: Object
},
computed: {
currentDay() {
return this.propsViewData?.focus_date || luxon.DateTime.now().setZone(this.viewData.timezone).toISODate();
},
currentMode() {
return this.propsViewData?.mode || DEFAULT_MODE_RAUMINFO;
}
},
methods:{
handleChangeDate(day, newMode) {
return this.handleChangeMode(newMode, day);
},
handleChangeMode(newMode, day) {
const mode = newMode[0].toUpperCase() + newMode.slice(1)
const focus_date = day.toISODate();
this.$router.push({
name: "RoomInformation",
params: {
mode,
focus_date,
ort_kurzbz: this.propsViewData.ort_kurzbz
}
});
},
getPromiseFunc(start, end) {
return [
this.$api.call(ApiLvPlan.getRoomInfo(this.propsViewData.ort_kurzbz, start.toISODate(), end.toISODate())),
this.$api.call(ApiLvPlan.getOrtReservierungen(this.propsViewData.ort_kurzbz, start.toISODate(), end.toISODate()))
];
}
},
template: /*html*/`
<div class="fhc-roominformation d-flex flex-column h-100">
<h2>{{ $p.t('rauminfo/rauminfo') }} {{ propsViewData.ort_kurzbz }}</h2>
<hr>
<fhc-calendar
ref="calendar"
:timezone="viewData.timezone"
:get-promise-func="getPromiseFunc"
:date="currentDay"
:mode="currentMode"
@update:date="handleChangeDate"
@update:mode="handleChangeMode"
class="responsive-calendar"
></fhc-calendar>
</div>`
};
+45
View File
@@ -0,0 +1,45 @@
import MylvSemesterStudiengang from "./Semester/Studiengang.js";
export default {
components: {
MylvSemesterStudiengang
},
provide() {
return {
studien_semester: Vue.computed(() => this.semester)
}
},
props: {
semester: [String, Number],
lvs: Array
},
computed: {
ready() { return this.lvs !== null; },
studiengaenge() {
return [... new Map(
this.lvs
.map(lv => [
lv.studiengang_kz + '#' + lv.semester,
{
studiengang_kz: lv.studiengang_kz,
bezeichnung: lv.sg_bezeichnung,
sg_bezeichnung_eng: lv.sg_bezeichnung_eng,
kuerzel: lv.studiengang_kuerzel,
semester: lv.semester
}
])
).values()].sort((a, b) => a.bezeichnung.toLowerCase() == b.bezeichnung.toLowerCase() ? a.semester > b.semester : a.bezeichnung.toLowerCase() > b.bezeichnung.toLowerCase());
},
},
methods: {
lvsForStudiengang(studiengang) {
return this.lvs.filter(lv => lv.studiengang_kz == studiengang.studiengang_kz && lv.semester == studiengang.semester);
}
},
template: `<div class="mylv-semester" v-if="ready">
<mylv-semester-studiengang v-for="studiengang in studiengaenge" :key="studiengang.studiengang_kz" v-bind="studiengang" :lvs="lvsForStudiengang(studiengang)"/>
</div>
<div class="mylv-semester text-center" v-else>
<i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>`
};
@@ -0,0 +1,46 @@
import MylvSemesterStudiengangLv from "./Studiengang/Lv.js";
import Phrasen from "../../../../mixins/Phrasen.js";
export default {
components: {
MylvSemesterStudiengangLv
},
mixins: [
Phrasen
],
props: {
bezeichnung: String,
kuerzel: String,
semester: [String,Number],
lvs: Array,
sg_bezeichnung_eng: String
},
computed: {
lehrveranstaltungen() {
return [... new Map(
this.lvs
.map(lv => [
lv.lehrveranstaltung_id,
lv
])
).values()]
}
},
methods: {
note(lv) {
return lv.benotung ? lv.znote || lv.lvnote || null : null;
}
},
template: `<div class="card mb-3">
<div class="card-body">
<h4 class="card-title mb-3">{{$p.user_language.value === 'English' ? sg_bezeichnung_eng : bezeichnung}} - {{kuerzel}}
<small>{{semester}}.{{$p.t('lehre/semester')}}</small>
</h4>
<div class="row">
<div v-for="lv in lehrveranstaltungen" :key="lv.lehrveranstaltung_id" class="col-12 col-md-6 col-xl-4 col-xxl-3 mb-3">
<mylv-semester-studiengang-lv v-bind="lv" class="text-center h-100"></mylv-semester-studiengang-lv>
</div>
</div>
</div>
</div>`
};
@@ -0,0 +1,226 @@
import LvPruefungen from "./Lv/Pruefungen.js";
import LvInfo from "./Lv/Info.js";
import Phrasen from "../../../../../mixins/Phrasen.js";
import LvUebersicht from "../../LvUebersicht.js";
import ApiLehre from '../../../../../api/factory/lehre.js';
import ApiAddons from '../../../../../api/factory/addons.js';
// TODO(chris): L10n
export default {
components:{
LvUebersicht,
},
mixins: [
Phrasen
],
inject: ['studien_semester'],
props: {
lehrveranstaltung_id: Number,
bezeichnung: String,
bezeichnung_eng: String,
module: String,
farbe: String,
lvinfo: Boolean,
benotung: Boolean,
lvnote: String,
lvnotebez: Array,
znote: String,
znotebez: Array,
studiengang_kuerzel: String,
semester: [String, Number],
orgform_kurzbz: String,
sprache: String,
ects: String,
incoming: Number,
positiv: Boolean,
note_index: String
},
data: () => {
return {
pruefungenData: null,
info: null,
menu: null,
preselectedMenuItem: null,
}
},
computed: {
gradeColor() {
// early return if value is null or undefined
if (this.positiv == null) return;
// returns a suitable color for the given grade
if (this.positiv)
{
return 'var(--fhc-success)';
}
else
{
return 'var(--fhc-danger)';
}
},
is_organisatorische_einheit(){
return this.menu == "organisatorische_einheit";
},
emptyMenu(){
return !this.menu || !Array.isArray(this.menu) || Array.isArray(this.menu) && this.menu.length == 0;
},
bodyStyle() {return {};
/*const bodyStyle = {};
if (this.farbe)
bodyStyle['background-color'] = '#' + this.farbe;
return bodyStyle;*/
},
grade() {
const languageIndex = this.$p.user_language.value === 'English' ? 1 : 0
if(this.benotung && this.znotebez?.length) {
return this.znotebez[languageIndex]
} else if(this.benotung && this.lvnotebez?.length) {
return this.lvnotebez[languageIndex]
} else return null
},
LvHasPruefungenInformation(){
return this.pruefungenData && this.pruefungenData.length > 0;
},
},
methods: {
fetchMenu(lehrveranstaltung_id = this.lehrveranstaltung_id, studien_semester = this.studien_semester) {
return this.$api
.call(ApiAddons.getLvMenu(lehrveranstaltung_id, studien_semester))
.then(res => {
this.menu = res.data;
})
.catch((error) => {
this.$fhcAlert.handleSystemError(error);
this.menu = [];
});
},
c4_link(menuItem) {
if (!menuItem) return null;
if (Array.isArray(menuItem.c4_moodle_links) && menuItem.c4_moodle_links.length) {
return '#';
}
else {
return menuItem.c4_link ?? null;
}
},
openLvOption(menuItem){
if (menuItem.id == "core_menu_mailanstudierende"){
window.location.href = menuItem.c4_link;
} else if (menuItem.id == "core_menu_digitale_anwesenheitslisten") {
window.location.href = menuItem.c4_link;
} else{
this.preselectedMenuItem = menuItem;
Vue.nextTick(() => {
this.$refs.lvUebersicht.show();
});
}
},
openPruefungen() {
// early return if the pruefungenData is empty or not set
if (!this.LvHasPruefungenInformation) return;
LvPruefungen.popup({
pruefungenData: this.pruefungenData,
bezeichnung: this.bezeichnung
});
},
openInfos() {
if (!this.info) {
this.info = true;
// TODO(chris): load all this params on ajax?
LvInfo.popup({
lehrveranstaltung_id: this.lehrveranstaltung_id,
bezeichnung: this.bezeichnung,
bezeichnung_eng: this.bezeichnung_eng,
studiengang_kuerzel: this.studiengang_kuerzel,
semester: this.semester,
studien_semester: this.studien_semester,
orgform_kurzbz: this.orgform_kurzbz,
sprache: this.sprache,
ects: this.ects,
incoming: this.incoming
}).then(() => this.info = false).catch(() => this.info = false);
}
}
},
watch:{
studien_semester(newValue){
this.fetchMenu(this.lehrveranstaltung_id, newValue);
}
},
created() {
this.$api
.call(ApiLehre.getStudentPruefungen(this.lehrveranstaltung_id))
.then(res => res.data)
.then(pruefungen => {
this.pruefungenData = pruefungen;
});
},
mounted() {
this.fetchMenu(this.lehrveranstaltung_id, this.studien_semester);
},
template: /*html*/`<div class="mylv-semester-studiengang-lv card">
<lv-uebersicht ref="lvUebersicht" :preselectedMenu="preselectedMenuItem" :event="{
lehrveranstaltung_id: lehrveranstaltung_id,
studiensemester_kurzbz:studien_semester,
lehrfach_bez:studien_semester,
stg_kurzbzlang:studien_semester,
}"/>
<div class="p-2" :class="is_organisatorische_einheit?'':'card-header'">
<!-- {{module}} if the module of the lv is important then query the module from the api endpoint for LV-->
<h6 class="fw-bold" v-if="is_organisatorische_einheit" >{{ $p.t('lehre/organisationseinheit') }}:</h6>
<h6 class="mb-0">{{$p.user_language.value === 'English' ? bezeichnung_eng : bezeichnung}}</h6>
</div>
<div v-if="!emptyMenu" class="card-body " :style="bodyStyle">
<template v-if="menu">
<ul class="list-group border-top-0 border-bottom-0 rounded-0">
<li :type="menuItem.c4_link ? 'button' : null" v-for="menuItem in menu" class="list-group-item border-0 " >
<div class="d-flex flex-row" :data-bs-toggle="menuItem.c4_moodle_links?.length ? 'dropdown' : null">
<div class="mx-4">
<i :class="[menuItem.c4_icon2 ? menuItem.c4_icon2 : 'fa-solid fa-pen-to-square', !menuItem.c4_link ? 'unavailable' : null ]"></i>
</div>
<a
class="fhc-body text-decoration-none text-truncate"
:id="'moodle_links_'+lehrveranstaltung_id"
:class="{ 'unavailable':!menuItem.c4_link, 'dropdown-toggle':menuItem.c4_moodle_links?.length }"
:target="menuItem.c4_target"
:href="c4_link(menuItem) ? c4_link(menuItem) : null">
{{ menuItem.phrase ? $p.t(menuItem.phrase) : menuItem.name}}
</a>
</div>
<ul v-if="menuItem.c4_moodle_links?.length" class="dropdown-menu p-0" :aria-labelledby="'moodle_links_'+lehrveranstaltung_id">
<li v-for="item in menuItem.c4_moodle_links"><a class="dropdown-item border-bottom" :href="item.url">{{item.lehrform}}</a></li>
</ul>
</li>
</ul>
</template>
<template v-else>
<div class="text-center d-flex justify-content-center align-items-center h-100" >
<i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
</template>
</div>
<div v-if="!emptyMenu" class="card-footer">
<div class="row">
<!-- template for the LV if there are multiple pruefungen -->
<template v-if="LvHasPruefungenInformation">
<a href="#" class="col-auto text-start text-decoration-none" @click.prevent="openPruefungen">
<i class="fa fa-check text-success" v-if="positiv"></i>
<span class="ps-1" :style="'color:'+gradeColor">{{ grade || $p.t('lehre/noGrades') }}</span>
</a>
</template>
<!-- template for the LV with no pruefungen -->
<template v-else>
<span class="col-auto text-start text-decoration-none" >
<i class="fa fa-check text-success" v-if="positiv"></i>
<span class="ps-1" :style="'color:'+gradeColor">{{ grade || $p.t('lehre/noGrades') }}</span>
</span>
</template>
</div>
</div>
</div>`
};
@@ -0,0 +1,184 @@
import ApiLehre from '../../../../../../api/factory/lehre.js';
const infos = {};
export default {
props:{
studien_semester: String,
lehrveranstaltung_id: Number,
},
data: () => ({
bezeichnung: null,
studiengang_kuerzel: null,
semester: null,
orgform_kurzbz: null,
sprache: null,
ects: null,
incoming: null,
result: true,
info: null,
}),
computed: {
lektorNamesLinks(){
let lektorenLinks = {};
this.info.lektoren.forEach(e => {
let name = ((e.titelpre || '') + ' ' + (e.vorname || '') + ' ' + (e.nachname || '') + ' ' + (e.titelpost || '')).trim();
lektorenLinks[name] = FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + `/Cis/Profil/View/${e.uid}`;
});
return lektorenLinks;
},
lektorNames(){
return this.info.lektoren.map((e)=>((e.titelpre || '') + ' ' + (e.vorname || '') + ' ' + (e.nachname || '') + ' ' + (e.titelpost || '')).trim());
},
lvLeitung() {
return this.info.lvLeitung && this.info.lvLeitung.length ? this.info.lvLeitung.map(e => ((e.titelpre || '') + ' ' + (e.vorname || '') + ' ' + (e.nachname || '') + ' ' + (e.titelpost || '')).trim()) : null;
},
oe() {
return this.info.oe.organisationseinheittyp ? (this.info.oe.organisationseinheittyp + ' ' + this.info.oe.bezeichnung) : '';
},
oeLeitung() {
if (!this.info.oeLeitung || !this.info.oeLeitung.length)
return ['-'];
return this.info.oeLeitung.map(e => ((e.titelpre || '') + ' ' + (e.vorname || '') + ' ' + (e.nachname || '') + ' ' + (e.titelpost || '')).trim());
},
koordinator() {
if (!this.info.koordinator || !this.info.koordinator.length)
return null;
return this.info.koordinator.map(e => ((e.titelpre || '') + ' ' + (e.vorname || '') + ' ' + (e.nachname || '') + ' ' + (e.titelpost || '')).trim());
},
currentLang() {
if (!this.info)
return null;
if (this.info.lastLang)
return this.info.lastLang;
if (!this.info.lvinfo)
return null;
return this.info.lvinfoDefaultLang && this.info.lvinfo[this.info.lvinfoDefaultLang] ? this.info.lvinfoDefaultLang : Object.keys(this.info.lvinfo).shift();
}
},
created() {
this.$api.call(ApiLehre.getLvInfo(this.studien_semester, this.lehrveranstaltung_id))
.then(
res => res.data
).then(data =>{
Object.assign(this,
{
bezeichnung : data.bezeichnung,
studiengang_kuerzel: data.studiengang_kuerzel,
semester: data.semester,
orgform_kurzbz: data.orgform_kurzbz,
sprache: data.sprache,
ects: data.ects,
incoming: data.incoming ?? '-',
});
})
if (infos[this.lehrveranstaltung_id]) {
this.info = infos[this.lehrveranstaltung_id];
} else {
axios.get(FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/components/Cis/Mylv/Info/' + this.studien_semester + '/' + this.lehrveranstaltung_id).then(res => {
this.info = infos[this.lehrveranstaltung_id] = res.data.retval || [];
}).catch(() => this.info = {});
}
},
template: /*html*/`
<h1>{{$p.t('lvinfo/lehrveranstaltungsinformationen')}}</h1>
<hr>
<div v-if="!info" class="text-center">
<i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
<table v-else class="table table-hover mb-4">
<tbody>
<tr>
<th>{{$p.t('lehre/lehrveranstaltung')}}</th>
<td>{{bezeichnung}}</td>
</tr>
<tr>
<th>{{$p.t('lehre/studiengang')}}</th>
<td>{{studiengang_kuerzel}}</td>
</tr>
<tr>
<th>{{$p.t('lehre/semester')}}</th>
<td>{{semester}}</td>
</tr>
<tr>
<th>{{$p.t('lehre/studiensemester')}}</th>
<td>{{studien_semester}}</td>
</tr>
<tr>
<th>{{$p.t('lehre/organisationsform')}}</th>
<td>{{orgform_kurzbz}}</td>
</tr>
<tr>
<th>{{$p.t('lehre/lehrbeauftragter')}}</th>
<td>
<ul v-if="lektorNames.length" class="list-unstyled mb-0">
<li v-for="name in new Set(lektorNames)" :key="name">
<a :href="lektorNamesLinks[name]?lektorNamesLinks[name]:null"><i class="fa fa-arrow-up-right-from-square me-1 fhc-primary-color" ></i></a>
{{name}}
</li>
</ul>
<template v-else>
{{$p.t('lehre/keinLektorZugeordnet')}}
</template>
</td>
</tr>
<tr v-if="lvLeitung">
<th>{{$p.t('lehre/lvleitung')}}</th>
<td>
<ul class="list-unstyled mb-0">
<li v-for="name in lvLeitung" :key="name">
<a :href="lektorNamesLinks[name]?lektorNamesLinks[name]:null"><i class="fa fa-arrow-up-right-from-square me-1 fhc-primary-color" ></i></a>
{{name}}
</li>
</ul>
</td>
</tr>
<tr>
<th>{{$p.t('global/sprache')}}</th>
<td>{{sprache}}</td>
</tr>
<tr>
<th>{{$p.t('lehre/ects')}}</th>
<td>{{ects}}</td>
</tr>
<tr>
<th>{{$p.t('lehre/incomingplaetze')}}</th>
<td>{{incoming}}</td>
</tr>
<tr>
<th>{{$p.t('lehre/organisationseinheit')}}</th>
<td>
{{oe}} <br>
(
<i>{{$p.t('global/leitung')}}: </i>{{oeLeitung.join(', ')}}
<template v-if="koordinator">
<i>{{$p.t('global/koordination')}}: </i>{{koordinator.join(', ')}}
</template>
)
</td>
</tr>
</tbody>
</table>
<div v-if="info && info.lvinfo">
<div v-if="Object.keys(info.lvinfo).length > 1" class="text-end">
<div class="btn-group" role="group" :title="$p.t('global/verfuegbareSprachen')" :aria-label="$p.t('global/verfuegbareSprachen')">
<template v-for="lang in info.sprachen" :key="lang.index">
<button v-if="info.lvinfo[lang.sprache]" type="button" class="btn btn-outline-primary" :class="lang.sprache == currentLang ? 'active' : ''" @click.prevent="info.lastLang = lang.sprache">{{lang.bezeichnung[lang.index-1]}}</button>
</template>
</div>
</div>
<template v-for="i in info.lvinfo[currentLang]" :key="info">
<h4>{{i.header}}</h4>
<h6 v-if="i.subheader">{{i.subheader}}</h6>
<ul v-if="Array.isArray(i.body)">
<li v-for="e in i.body" :key="e">{{e}}</li>
</ul>
<p v-else>
{{i.body}}
</p>
</template>
</div>`
}
@@ -0,0 +1,63 @@
import BsModal from '../../../../../Bootstrap/Modal.js';
const pruefungen = {};
export default {
components: {
BsModal
},
mixins: [
BsModal
],
props: {
pruefungenData: Array|null,
bezeichnung: String,
/*
* NOTE(chris):
* Hack to expose in "emits" declared events to $props which we use
* in the v-bind directive to forward all events.
* @see: https://github.com/vuejs/core/issues/3432
*/
onHideBsModal: Function,
onHiddenBsModal: Function,
onHidePreventedBsModal: Function,
onShowBsModal: Function,
onShownBsModal: Function
},
data: () => ({
result: true,
}),
mounted() {
this.modal = this.$refs.modalContainer.modal;
},
popup(options) {
return BsModal.popup.bind(this)(null, options);
},
template: `<bs-modal ref="modalContainer" class="bootstrap-alert" v-bind="$props" body-class="">
<template v-slot:title>
Prüfungen: {{bezeichnung}}
</template>
<template v-slot:default>
<div v-if="!pruefungenData" class="text-center">
<i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>
<p v-else-if="!pruefungenData.length" class="alert alert-info mb-0">
Keine Prüfungen vorhanden!
</p>
<table v-else class="table table-hover">
<thead>
<td>&nbsp;</td>
<td>Datum</td>
<td class="text-end">Note</td>
</thead>
<tbody>
<tr v-for="pruefung in pruefungenData" :key="pruefung.pruefung_id">
<th>{{pruefung.pruefungstyp_kurzbz}}</th>
<td>{{pruefung.datum}}</td>
<td class="text-end">{{pruefung.note}}</td>
</tr>
</tbody>
</table>
</template>
</bs-modal>`
}
+129
View File
@@ -0,0 +1,129 @@
import MylvSemester from "./Semester.js";
import Phrasen from "../../../mixins/Phrasen.js";
// TODO(chris): phrase: global/studiensemester_auswaehlen
// TODO(chris): phrase: next & prev +aria-label
export default {
components: {
MylvSemester
},
mixins: [
Phrasen
],
data: () => {
return {
firstLoad: true,
studiensemester: null,
lvs: {},
currentSemester: null
};
},
computed: {
ready() {
return this.studiensemester !== null && (!this.firstLoad || this.current.lvs !== null);
},
current() {
if (this.currentSemester === null)
return { semester: null, lvs: [] };
if (this.lvs[this.currentSemester] === undefined) {
this.lvs[this.currentSemester] = {
semester: this.currentSemester,
lvs: null
};
axios.get(FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/components/Cis/Mylv/Lvs/' + this.currentSemester).then(res => {
this.lvs[this.currentSemester].lvs = res.data.retval || [];
this.firstLoad = false;
});
}
return this.lvs[this.currentSemester];
},
nearestSem() {
let now = Date.now();
let nearestSem = null;
let nearestSemDiff = 0;
this.studiensemester.forEach(sem => {
let start = new Date(sem.start);
let end = new Date(sem.ende);
if (now >= start && now <= end) {
nearestSem = sem.studiensemester_kurzbz;
nearestSemDiff = 0;
return;
}
let diff = Math.min(Math.abs(now - start), Math.abs(now - end));
if (nearestSem === null || diff < nearestSemDiff) {
nearestSem = sem.studiensemester_kurzbz;
nearestSemDiff = diff;
}
});
return nearestSem;
},
currentIsFirst() {
return this.studiensemester[0].studiensemester_kurzbz == this.currentSemester;
},
currentIsLast() {
return this.studiensemester[this.studiensemester.length-1].studiensemester_kurzbz == this.currentSemester;
}
},
methods: {
prevSem() {
this.$refs.studiensemester.selectedIndex--;
this.$refs.studiensemester.dispatchEvent(new Event('change', { bubbles: true }));
},
nextSem() {
this.$refs.studiensemester.selectedIndex++;
this.$refs.studiensemester.dispatchEvent(new Event('change', { bubbles: true }));
},
updateRouter(val) {
this.$router.push(`/Cis/MyLv/${val}`);
}
},
created() {
axios.get(FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/components/Cis/Mylv/Studiensemester').then(res => {
this.studiensemester = res.data.retval || [];
const routerStudiensemester = this.$route.params.studiensemester;
if (routerStudiensemester && this.studiensemester.filter(s => s.studiensemester_kurzbz == routerStudiensemester).length)
this.currentSemester = routerStudiensemester;
else
this.currentSemester = this.nearestSem;
});
},
beforeRouteUpdate(to, from, next){
if (to.params.studiensemester && this.studiensemester.filter(s => s.studiensemester_kurzbz == to.params.studiensemester).length && to.params.studiensemester != this.currentSemester)
this.currentSemester = to.params.studiensemester;
next();
},
template: `
<h2>{{$p.t('lehre/myLV')}}</h2>
<hr>
<div class="mylv-student" v-if="ready">
<div v-if="currentSemester" class="row justify-content-center mb-3">
<div class="col-auto d-none">
<label class="col-form-label">{{$p.t('lehre/studiensemester')}}</label>
</div>
<div class="col-auto">
<div class="input-group">
<button :aria-label="$p.t('lehre','previousStudSemester')" v-tooltip.top="{showDelay:1000, value:$p.t('lehre','previousStudSemester')}" class="btn btn-outline-secondary" type="button" :disabled="currentIsFirst" @click="prevSem">
<i class="fa fa-caret-left" aria-hidden="true"></i>
</button>
<select ref="studiensemester" v-model="currentSemester" class="form-select" :aria-label="$p.t('global/studiensemester_auswaehlen')" @change="updateRouter($event.target.value)">
<option v-for="semester in studiensemester" :key="semester.studiensemester_kurzbz">{{semester.studiensemester_kurzbz}}</option>
</select>
<button class="btn btn-outline-secondary" :aria-label="$p.t('lehre','nextStudSemester')" v-tooltip.top="{showDelay:1000, value:$p.t('lehre','nextStudSemester')}" type="button" :disabled="currentIsLast" @click="nextSem">
<i class="fa fa-caret-right" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<div class="alert alert-danger" role="alert" v-else>
{{$p.t('lehre/noLvFound')}}
</div>
<mylv-semester v-bind="current"/>
</div>
<div class="mylv-student text-center" v-else>
<i class="fa-solid fa-spinner fa-pulse fa-3x"></i>
</div>`
};
@@ -0,0 +1,497 @@
import {CoreFilterCmpt} from "../../../components/filter/Filter.js";
import EditProfil from "./ProfilModal/EditProfil.js";
import Adresse from "./ProfilComponents/Adresse.js";
import Kontakt from "./ProfilComponents/Kontakt.js";
import FetchProfilUpdates from "./ProfilComponents/FetchProfilUpdates.js";
import Mailverteiler from "./ProfilComponents/Mailverteiler.js";
import AusweisStatus from "./ProfilComponents/FhAusweisStatus.js";
import QuickLinks from "./ProfilComponents/QuickLinks.js";
import ProfilEmails from "./ProfilComponents/ProfilEmails.js";
import RoleInformation from "./ProfilComponents/RoleInformation.js";
import ProfilInformation from "./ProfilComponents/ProfilInformation.js";
import ApiProfilUpdate from '../../../api/factory/profilUpdate.js';
export default {
components: {
CoreFilterCmpt,
EditProfil,
Adresse,
Kontakt,
FetchProfilUpdates,
AusweisStatus,
Mailverteiler,
QuickLinks,
ProfilEmails,
RoleInformation,
ProfilInformation,
},
inject: ["sortProfilUpdates", "collapseFunction", "language","isEditable"],
data() {
return {
showModal: false,
editDataFilter: null,
preloadedPhrasen:{},
// tabulator options
funktionen_table_options: {
persistenceID: "filterTableMaProfilFunktionen",
persistence: {
columns: false
},
height: 300,
layout: "fitColumns",
responsiveLayout: "collapse",
responsiveLayoutCollapseUseFormatters: false,
responsiveLayoutCollapseFormatter: Vue.$collapseFormatter,
columns: [
{
title:
"<i id='collapseIconFunktionen' role='button' class='fa-solid fa-angle-down '></i>",
field: "collapse",
headerSort: false,
headerFilter: false,
formatter: "responsiveCollapse",
maxWidth: 40,
headerClick: this.collapseFunction,
visible: true
},
{
title: Vue.computed(() => this.preloadedPhrasen.bezeichnungPhrase),
field: "Bezeichnung",
headerFilter: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() => this.preloadedPhrasen.organisationseinheitPhrase),
field: "Organisationseinheit",
headerFilter: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() => this.preloadedPhrasen.gueltigVonPhrase),
field: "Gültig_von",
headerFilter: true,
resizable: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() => this.preloadedPhrasen.gueltigBisPhrase),
field: "Gültig_bis",
headerFilter: true,
resizable: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() => this.preloadedPhrasen.wochenstundenPhrase),
field: "Wochenstunden",
headerFilter: true,
minWidth: 200,
visible: true
},
],
},
betriebsmittel_table_options: {
persistenceID: "filterTableMaProfilBetriebsmittel",
persistence: {
columns: false
},
height: 300,
layout: "fitColumns",
responsiveLayout: "collapse",
responsiveLayoutCollapseUseFormatters: false,
responsiveLayoutCollapseFormatter: Vue.$collapseFormatter,
data: [{betriebsmittel: "", Nummer: "", Ausgegeben_am: ""}],
columns: [
{
title:
"<i id='collapseIconBetriebsmittel' role='button' class='fa-solid fa-angle-down '></i>",
field: "collapse",
headerSort: false,
headerFilter: false,
formatter: "responsiveCollapse",
maxWidth: 40,
headerClick: this.collapseFunction,
visible: true
},
{
title: Vue.computed(() => this.preloadedPhrasen.entlehnteBetriebsmittelPhrase),
field: "betriebsmittel",
headerFilter: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() => this.preloadedPhrasen.inventarnummerPhrase),
field: "Nummer",
headerFilter: true,
resizable: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() => this.preloadedPhrasen.ausgabedatumPhrase),
field: "Ausgegeben_am",
headerFilter: true,
minWidth: 200,
visible: true
},
],
}
};
},
props: {
data: Object,
editData: Object,
},
methods: {
betriebsmittelTableBuilt: function () {
this.$refs.betriebsmittelTable.tabulator.setColumns(this.betriebsmittel_table_options.columns)
this.$refs.betriebsmittelTable.tabulator.setData(this.data.mittel);
},
funktionenTableBuilt: function () {
this.$refs.funktionenTable.tabulator.setColumns(this.funktionen_table_options.columns)
this.$refs.funktionenTable.tabulator.setData(this.data.funktionen);
},
hideEditProfilModal: function () {
//? checks the editModal component property result, if the user made a successful request or not
if (this.$refs.editModal.result) {
this.$api
.call(ApiProfilUpdate.selectProfilRequest())
.then((request) => {
if (!request.error && request.data) {
this.data.profilUpdates = request.data;
this.data.profilUpdates.sort(this.sortProfilUpdates);
} else {
console.error("Error when fetching profile updates: " + request);
}
})
.catch((err) => {
console.error(err);
});
} else {
// when modal was closed without submitting request
}
this.showModal = false;
this.editDataFilter = null;
},
showEditProfilModal(view) {
if (view) {
this.editDataFilter = view;
}
this.showModal = true;
Vue.nextTick(() => {
this.$refs.editModal.show();
});
// after a state change, wait for the DOM updates to complete
},
fetchProfilUpdates: function () {
this.$api
.call(ApiProfilUpdate.selectProfilRequest())
.then((res) => {
if (!res.error && res) {
this.data.profilUpdates = res.data?.length
? res.data.sort(this.sortProfilUpdates)
: null;
}
});
},
setTableColumnTitles() { // reevaluates computed phrasen
if(this.$refs.betriebsmittelTable) this.$refs.betriebsmittelTable.tabulator.setColumns(this.betriebsmittel_table_options.columns)
if(this.$refs.funktionenTable) this.$refs.funktionenTable.tabulator.setColumns(this.funktionen_table_options.columns)
}
},
computed: {
fotoStatus() {
return this.data?.fotoStatus ?? null;
},
getTelefonValue() {
if(this.data.standort_telefon?.kontakt) {
return this.data.standort_telefon.kontakt + " " + this.data.telefonklappe
} else if(this.data.standort_telefon) {
return this.data.standort_telefon + " " + this.data.telefonklappe
} else {
return this.data.telefonklappe
}
},
filteredEditData() {
return this.editDataFilter
? this.editData.data[this.editDataFilter]
: this.editData;
},
profilInformation() {
if (!this.data) {
return {};
}
return {
Vorname: this.data.vorname,
Nachname: this.data.nachname,
Username: this.data.username,
Anrede: this.data.anrede,
Titel: this.data.titel,
Postnomen: this.data.postnomen,
foto_sperre: this.data.foto_sperre,
foto: this.data.foto,
};
},
roleInformation() {
if (!this.data) {
return {};
}
return {
geburtsdatum: {
label: `${this.$p.t('profil','Geburtsdatum')}`,
value: this.data.gebdatum
},
geburtsort: {
label: `${this.$p.t('profil','Geburtsort')}`,
value: this.data.gebort
},
personenkennzeichen: {
label: `${this.$p.t('profil','Kurzzeichen')}`,
value: this.data.kurzbz
},
telefon: {
label: `${this.$p.t('profil','Telefon')}`,
value: this.getTelefonValue
},
office: {
label: `${this.$p.t('profil','Büro')}`,
value: this.data.ort_kurzbz
}
};
},
},
created() {
// preload phrasen
this.$p.loadCategory(["ui","lehre","global","profil"]).then(() => {
this.preloadedPhrasen.bezeichnungPhrase = this.$p.t('ui/bezeichnung');
this.preloadedPhrasen.organisationseinheitPhrase = this.$p.t('lehre/organisationseinheit');
this.preloadedPhrasen.gueltigVonPhrase = this.$p.t('global/gueltigVon');
this.preloadedPhrasen.gueltigBisPhrase = this.$p.t('global/gueltigBis');
this.preloadedPhrasen.wochenstundenPhrase = this.$p.t('profil/wochenstunden');
this.preloadedPhrasen.entlehnteBetriebsmittelPhrase = this.$p.t('profil/entlehnteBetriebsmittel');
this.preloadedPhrasen.inventarnummerPhrase = this.$p.t('profil/inventarnummer');
this.preloadedPhrasen.ausgabedatumPhrase = this.$p.t('profil/ausgabedatum');
this.preloadedPhrasen.loaded=true;
});
//? sorts the profil Updates: pending -> accepted -> rejected
this.data.profilUpdates?.sort(this.sortProfilUpdates);
},
watch: {
'data.funktionen'(newVal) {
if(this.$refs.funktionenTable) this.$refs.funktionenTable.tabulator.setData(newVal);
},
'data.mittel'(newVal) {
if(this.$refs.betriebsmittelTable) this.$refs.betriebsmittelTable.tabulator.setData(newVal);
},
'language.value'(newVal) {
this.setTableColumnTitles()
}
},
template: /*html*/ `
<div class="container-fluid text-break fhc-form" >
<edit-profil v-if="showModal" ref="editModal" @hideBsModal="hideEditProfilModal" :value="JSON.parse(JSON.stringify(filteredEditData))" :titel="$p.t('profil','profilBearbeiten')"></edit-profil>
<div class="row">
<div class="d-md-none col-12 ">
<!--TODO: uncomment when implemented
<div class="row mb-3">
<div class="col">
<quick-links :title="$p.t('profil','quickLinks')" :mobile="true"></quick-links>
</div>
</div>-->
<!-- Bearbeiten Button -->
<div v-if="isEditable" class="row mb-3 ">
<div class="col">
<button @click="()=>showEditProfilModal()" type="button" class="text-start card w-100 btn btn-outline-secondary" >
<div class="row">
<div class="col-auto">
<i class="fa fa-edit"></i>
</div>
<div class="col-auto">{{$p.t('ui','bearbeiten')}}</div>
</div>
</button>
</div>
</div>
<div v-if="data.profilUpdates" class="row mb-3">
<div class="col">
<!-- MOBILE PROFIL UPDATES -->
<fetch-profil-updates v-if="data.profilUpdates && data.profilUpdates.length" @fetchUpdates="fetchProfilUpdates" :data="data.profilUpdates" ></fetch-profil-updates>
</div>
</div>
</div>
<!-- END OF HIDDEN ROW (HIDDEN IN VIEWPORTS GREATER THEN-EQUAL MD) -->
<!-- MAIN PANNEL -->
<div class="col-sm-12 col-md-8 col-xxl-9 ">
<!-- ROW WITH PROFIL IMAGE AND INFORMATION -->
<!-- INFORMATION CONTENT START -->
<!-- ROW WITH THE PROFIL INFORMATION -->
<div class="row mb-4">
<div class="col-lg-12 col-xl-6 ">
<div class="row mb-4">
<div class="col">
<!-- PROFIL INFORMATION -->
<profil-information @showEditProfilModal="showEditProfilModal" :title="$p.t('profil','mitarbeiterIn')" :data="profilInformation" :fotoStatus="fotoStatus"></profil-information>
</div>
</div>
<div class="row mb-4">
<div class=" col-lg-12">
<!-- MITARBEITER INFO -->
<role-information :title="$p.t('profil','mitarbeiterInformation')" :data="roleInformation"></role-information>
</div>
</div>
<!-- START OF SECOND PROFIL INFORMATION COLUMN -->
</div>
<div class="col-xl-6 col-lg-12 ">
<div class="row mb-4">
<div class="col">
<!-- EMAILS -->
<profil-emails :title="this.$p.t('person','email')" :data="data.emails" ></profil-emails>
</div>
</div>
<div class="row mb-4 ">
<div class="col">
<!-- PRIVATE KONTAKTE-->
<div class="card">
<div class="card-header">
<div class="row">
<div @click="showEditProfilModal('Private_Kontakte')" class="col-auto" type="button">
<i class="fa fa-edit"></i>
</div>
<div class="col">
<span>{{$p.t('profil','privateKontakte')}}</span>
</div>
</div>
</div>
<div class="card-body ">
<div class="gy-3 row ">
<div v-for="element in data.kontakte" class="col-12">
<Kontakt :data="element"></Kontakt>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col">
<!-- PRIVATE ADRESSEN-->
<div class="card">
<div class="card-header">
<div class="row">
<div @click="showEditProfilModal('Private_Adressen')" class="col-auto" type="button">
<i class="fa fa-edit"></i>
</div>
<div class="col">
<span>{{$p.t('profil','privateAdressen')}}</span>
</div>
</div>
</div>
<div class="card-body">
<div class="gy-3 row ">
<div v-for="element in data.adressen" class="col-12">
<Adresse :data="element"></Adresse>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div >
<div class="row">
<div class="col-12 mb-4" >
<!-- FUNKTIONEN TABELLE -->
<core-filter-cmpt
v-if="preloadedPhrasen.loaded"
@tableBuilt="funktionenTableBuilt"
:title="$p.t('person','funktionen')"
ref="funktionenTable"
:tabulator-options="funktionen_table_options"
tableOnly
:sideMenu="false"
/>
</div>
<div class="col-12 mb-4" >
<!-- BETRIEBSMITTEL TABELLE -->
<core-filter-cmpt
v-if="preloadedPhrasen.loaded"
@tableBuilt="betriebsmittelTableBuilt"
:title="$p.t('profil','entlehnteBetriebsmittel')"
ref="betriebsmittelTable"
:tabulator-options="betriebsmittel_table_options"
tableOnly
:sideMenu="false"
/>
</div>
</div>
</div>
<!-- START OF SIDE PANEL -->
<div class="col-md-4 col-xxl-3 col-sm-12 text-break" >
<!--TODO: uncomment when implemented
<div class="row d-none d-md-block mb-3">
<div class="col">
<quick-links :title="$p.t('profil','quickLinks')"></quick-links>
</div>
</div>-->
<!-- Bearbeiten Button -->
<div class="row d-none d-md-block ">
<div class="col mb-3">
<button @click="()=>showEditProfilModal()" type="button" class="text-start card w-100 btn btn-outline-secondary" >
<div class="row">
<div class="col-auto">
<i class="fa fa-edit"></i>
</div>
<div class="col-auto">{{$p.t('ui','bearbeiten')}}</div>
</div>
</button>
</div>
</div>
<div v-if="data.profilUpdates" class="row d-none d-md-block mb-3">
<div class="col mb-3">
<!-- PROFIL UPDATES -->
<fetch-profil-updates v-if="data.profilUpdates && data.profilUpdates.length" @fetchUpdates="fetchProfilUpdates" :data="data.profilUpdates"></fetch-profil-updates>
</div>
</div>
<div class="row mb-3" >
<div class="col-12">
<!-- AUSWEIS STATUS -->
<ausweis-status :data="data.zutrittsdatum"></ausweis-status>
</div>
</div>
<div class="row">
<div class="col">
<!-- MAILVERTEILER -->
<mailverteiler :data="data?.mailverteiler" :title="$p.t('profil','mailverteiler')"></mailverteiler>
</div>
</div>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,264 @@
import {CoreFilterCmpt} from "../../../components/filter/Filter.js";
import Mailverteiler from "./ProfilComponents/Mailverteiler.js";
import QuickLinks from "./ProfilComponents/QuickLinks.js";
import RoleInformation from "./ProfilComponents/RoleInformation.js";
import ProfilEmails from "./ProfilComponents/ProfilEmails.js";
import ProfilInformation from "./ProfilComponents/ProfilInformation.js";
export default {
components: {
CoreFilterCmpt,
Mailverteiler,
QuickLinks,
RoleInformation,
ProfilEmails,
ProfilInformation,
},
inject: ["collapseFunction", "language"],
data() {
return {
collapseIconFunktionen: true,
preloadedPhrasen:{},
funktionen_table_options: {
persistenceID: "filterTableMaViewProfilFunktionen",
persistence: {
columns: false
},
height: 300,
layout: "fitColumns",
responsiveLayout: "collapse",
responsiveLayoutCollapseUseFormatters: false,
responsiveLayoutCollapseFormatter: Vue.$collapseFormatter,
columns: [
//? option when wanting to hide the collapsed list
{
title:
"<i id='collapseIconFunktionen' role='button' class='fa-solid fa-angle-down '></i>",
field: "collapse",
headerSort: false,
headerFilter: false,
formatter: "responsiveCollapse",
maxWidth: 40,
headerClick: this.collapseFunction,
visible: true
},
{
title: Vue.computed(() => this.$p.t('ui/bezeichnung')),
field: "Bezeichnung",
headerFilter: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() => this.$p.t('lehre/organisationseinheit')),
field: "Organisationseinheit",
headerFilter: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() => this.$p.t('global/gueltigVon')),
field: "Gültig_von",
headerFilter: true,
resizable: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() => this.$p.t('global/gueltigBis')),
field: "Gültig_bis",
headerFilter: true,
resizable: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() => this.$p.t('profil/wochenstunden')),
field: "Wochenstunden",
headerFilter: true,
minWidth: 200,
visible: true
},
],
},
};
},
//? this is the prop passed to the dynamic component with the custom data of the view
props: ["data"],
methods: {
funktionenTableBuilt: function () {
this.$refs.funktionenTable.tabulator.setData(this.data.funktionen);
},
},
watch: {
'data.funktionen'(newVal) {
if(this.$refs.funktionenTable) this.$refs.funktionenTable.tabulator.setData(newVal);
},
'language.value'(newVal) { // reevaluates computed phrasen
if(this.$refs.funktionenTable) this.$refs.funktionenTable.tabulator.setColumns(this.funktionen_table_options.columns)
}
},
computed: {
getTelefonValue() {
if(this.data.standort_telefon?.kontakt) {
return this.data.standort_telefon.kontakt + " " + this.data.telefonklappe
} else if(this.data.standort_telefon) {
return this.data.standort_telefon + " " + this.data.telefonklappe
} else {
return this.data.telefonklappe
}
},
fotoStatus() {
return this.data?.fotoStatus ?? null;
},
personEmails() {
return this.data?.emails ? this.data.emails : [];
},
profilInformation() {
if (!this.data) {
return {};
}
return {
Vorname: this.data.vorname,
Nachname: this.data.nachname,
Username: this.data.username,
Anrede: this.data.anrede,
Titel: this.data.titel,
Postnomen: this.data.postnomen,
foto_sperre: this.data.foto_sperre,
foto: this.data.foto,
};
},
roleInformation() {
if (!this.data) {
return {};
}
return {
geburtsdatum: {
label: `${this.$p.t('profil','Geburtsdatum')}`,
value: this.data.gebdatum
},
geburtsort: {
label: `${this.$p.t('profil','Geburtsort')}`,
value: this.data.gebort
},
personenkennzeichen: {
label: `${this.$p.t('profil','Kurzzeichen')}`,
value: this.data.kurzbz
},
telefon: {
label: `${this.$p.t('profil','Telefon')}`,
value: this.getTelefonValue
},
office: {
label: `${this.$p.t('profil','Büro')}`,
value: this.data.ort_kurzbz
}
};
},
},
created(){
this.$p.loadCategory(["ui", "lehre", "global", "profil"]).then(() => {
this.preloadedPhrasen.bezeichnungPhrase = this.$p.t('ui/bezeichnung');
this.preloadedPhrasen.organisationseinheitPhrase = this.$p.t('lehre/organisationseinheit');
this.preloadedPhrasen.gueltigVonPhrase = this.$p.t('global/gueltigVon');
this.preloadedPhrasen.gueltigBisPhrase = this.$p.t('global/gueltigBis');
this.preloadedPhrasen.wochenstundenPhrase = this.$p.t('profil/wochenstunden');
this.preloadedPhrasen.loaded = true;
});
},
template: /*html*/ `
<div class="container-fluid text-break fhc-form" >
<!-- ROW -->
<div class="row">
<!-- HIDDEN QUICK LINKS -->
<!-- TODO: uncomment when implemented
<div class="d-md-none col-12 ">
<quick-links :title="$p.t('profil','quickLinks')" :mobile="true" ></quick-links>
</div>
-->
<!-- END OF HIDDEN QUCK LINKS -->
<!-- MAIN PANNEL -->
<div class="col-sm-12 col-md-8 col-xxl-9 ">
<!-- ROW WITH PROFIL IMAGE AND INFORMATION -->
<!-- INFORMATION CONTENT START -->
<!-- ROW WITH THE PROFIL INFORMATION -->
<div class="row mb-4">
<!-- FIRST KAESTCHEN -->
<div class="col-lg-12 col-xl-6 ">
<div class="row mb-4">
<div class="col">
<!-- Profil Informationen -->
<profil-information :title="$p.t('profil','mitarbeiterIn')" :data="profilInformation" :fotoStatus="fotoStatus"></profil-information>
</div>
</div>
<!-- START OF SECOND PROFIL INFORMATION COLUMN -->
<!-- END OF PROFIL INFORMATION ROW -->
<!-- INFORMATION CONTENT END -->
</div>
<div class="col-xl-6 col-lg-12 ">
<div class="row mb-4">
<div class="col">
<!-- EMAILS -->
<profil-emails :title="this.$p.t('person','email')" :data="personEmails"></profil-emails>
</div>
</div>
<!-- SECOND ROW OF SECOND COLUMN IN MAIN CONTENT -->
<div class="row mb-4">
<div class=" col-lg-12">
<!-- roleInformation -->
<role-information :data="roleInformation" :title="$p.t('profil','mitarbeiterInformation')"></role-information>
</div>
</div>
<!-- END OF SECOND ROW OF SECOND COLUMN IN MAIN CONTENT -->
<!-- END OF THE SECOND INFORMATION COLUMN -->
</div>
<!-- START OF THE SECOND PROFIL INFORMATION ROW -->
<!-- ROW WITH PROFIL IMAGE AND INFORMATION END -->
</div >
<!-- SECOND ROW UNDER THE PROFIL IMAGE AND INFORMATION WITH THE TABLES -->
<div class="row">
<!-- FIRST TABLE -->
<div class="col-12 mb-4" >
<core-filter-cmpt v-if="preloadedPhrasen.loaded" @tableBuilt="funktionenTableBuilt" :title="$p.t('person','funktionen')" ref="funktionenTable" :tabulator-options="funktionen_table_options" tableOnly :sideMenu="false" />
</div>
<!-- END OF THE ROW WITH THE TABLES UNDER THE PROFIL INFORMATION -->
</div>
<!-- END OF MAIN CONTENT COL -->
</div>
<!-- START OF SIDE PANEL -->
<div class="col-md-4 col-xxl-3 col-sm-12 text-break" >
<!-- VISIBLE UNTIL VIEWPORT MD -->
<!--TODO: uncomment when implemented
<div class="row d-none d-md-block mb-3">
<div class="col">
<quick-links :title="$p.t('profil','quickLinks')" ></quick-links>
</div>
</div>
-->
<div class="row">
<div class="col">
<!-- MAILVERTEILER -->
<mailverteiler :data="data?.mailverteiler" :title="$p.t('profil','mailverteiler')"></mailverteiler>
</div>
</div>
<!-- END OF SIDE PANEL -->
</div>
<!-- END OF CONTAINER ROW-->
</div>
<!-- END OF CONTAINER -->
</div>
`,
};
+390
View File
@@ -0,0 +1,390 @@
import StudentProfil from "./StudentProfil.js";
import MitarbeiterProfil from "./MitarbeiterProfil.js";
import ViewStudentProfil from "./StudentViewProfil.js";
import ViewMitarbeiterProfil from "./MitarbeiterViewProfil.js";
import Loading from "../../Loader.js";
import ApiProfil from '../../../api/factory/profil.js';
import ApiProfilUpdate from '../../../api/factory/profilUpdate.js';
Vue.$collapseFormatter = function (data) {
//data - an array of objects containing the column title and value for each cell
var container = document.createElement("div");
container.classList.add("tabulator-collapsed-row");
container.classList.add("text-break");
var list = document.createElement("div");
list.classList.add("row");
container.appendChild(list);
data.forEach(function (col) {
let item = document.createElement("div");
item.classList.add("col-6");
let item2 = document.createElement("div");
item2.classList.add("col-6");
item.innerHTML = "<strong>" + col.title + "</strong>";
item2.innerHTML = col.value ? col.value : "-";
list.appendChild(item);
list.appendChild(item2);
});
return Object.keys(data).length ? container : "";
};
export const Profil = {
name: 'Profil',
components: {
StudentProfil,
MitarbeiterProfil,
ViewStudentProfil,
ViewMitarbeiterProfil,
Loading,
},
props: {
uid: {
type: String,
required:false,
},
viewData: {
type: Object,
}
},
data() {
return {
//? loading property is used for showing/hiding the loading modal
loading: false,
profilUpdateStates: null,
profilUpdateTopic: null,
view: null,
data: null,
// notfound is null by default, but contains an UID if no user exists with that UID
notFoundUID: null,
isEditable: this.viewData.editable ?? false,
};
},
provide() {
return {
isEditable: Vue.computed(()=>this.isEditable),
profilUpdateStates: Vue.computed(() =>
this.profilUpdateStates ? this.profilUpdateStates : false
),
profilUpdateTopic: Vue.computed(() =>
this.profilUpdateTopic ? this.profilUpdateTopic : false
),
setLoading: (newValue) => {
this.loading = newValue;
},
getZustellkontakteCount: this.zustellKontakteCount,
getZustelladressenCount: this.zustellAdressenCount,
collapseFunction: (e, column) => {
//* check if property doesn't exist already and add it to the reactive this properties
if (this[e.target.id] === undefined) {
this[e.target.id] = true;
}
this[e.target.id] = !this[e.target.id];
//* gets all event icons of the different rows to use the onClick event later
let allClickableIcons = column._column.cells.map((row) => {
return row.element.children[0];
});
//* changes the icon that shows or hides all the collapsed columns
//* if the replace function does not find the class to replace, it just simply returns false
if (this[e.target.id]) {
e.target.classList.replace("fa-angle-up", "fa-angle-down");
} else {
e.target.classList.replace("fa-angle-down", "fa-angle-up");
}
//* changes the icon for every collapsed column to open or closed
if (this[e.target.id]) {
allClickableIcons
.filter((column) => {
return !column.classList.contains("open");
})
.forEach((col) => {
col.click();
});
} else {
allClickableIcons
.filter((column) => {
return column.classList.contains("open");
})
.forEach((col) => {
col.click();
});
}
},
sortProfilUpdates: (ele1, ele2) => {
let result = 0;
if (ele1.status === "pending") {
result = -1;
} else if (ele1.status === "accepted") {
result = ele2.status === "rejected" ? -1 : 1;
} else {
result = 1;
}
//? if they have the same status the insert date is used for ordering
if (ele1.status === ele2.status) {
result =
new Date(ele2.insertamum.split(".").reverse().join("-")) -
new Date(ele1.insertamum.split(".").reverse().join("-"));
}
return result;
},
};
},
methods: {
async load() {
// fetch profilUpdateStates to provide them to children components
await this.$api
.call(ApiProfilUpdate.getStatus())
.then((response) => {
this.profilUpdateStates = response.data;
})
.catch((error) => {
console.error(error);
});
this.$api
.call(ApiProfilUpdate.getTopic())
.then((response) => {
this.profilUpdateTopic = response.data;
})
.catch((error) => {
console.error(error);
});
this.$api
.call(ApiProfil.profilViewData(this.$route.params.uid??null))
.then((response) => response.data).then(data=>{
this.view = data?.profil_data.view;
this.data = data?.profil_data.data;
this.isEditable = data?.editable ?? false;
})
.catch((error) => {
console.error(error);
});
},
zustellAdressenCount() {
if (!this.data || !this.data.adressen) {
return null;
}
let adressenArray = [];
if (this.data.profilUpdates?.length) {
adressenArray = adressenArray.concat(
this.data.profilUpdates
.filter((update) => {
return update.requested_change.zustelladresse;
})
.map((adresse) => {
return adresse.requested_change.adresse_id;
})
);
}
if (
!this.data.profilUpdates?.length ||
!this.data.adressen
.filter((adresse) => adresse.zustelladresse)
.every((adresse) =>
this.data.profilUpdates.some(
(update) =>
update.requested_change.adresse_id == adresse.adresse_id
)
)
) {
adressenArray = adressenArray.concat(
this.data.adressen
.filter((adresse) => {
return adresse.zustelladresse;
})
.map((adr) => {
return adr.adresse_id;
})
);
}
return [...new Set(adressenArray)];
},
zustellKontakteCount() {
if (!this.data || !this.data.kontakte) {
return null;
}
let kontakteArray = [];
if (this.data.profilUpdates?.length) {
kontakteArray = kontakteArray.concat(
this.data.profilUpdates
.filter((update) => {
return update.requested_change.zustellung;
})
.map((kontant) => {
return kontant.requested_change.kontakt_id;
})
);
}
if (
!this.data.profilUpdates?.length ||
!this.data.kontakte
.filter((kontakt) => kontakt.zustellung)
.every((kontakt) =>
this.data.profilUpdates.some(
(update) =>
update.requested_change.kontakt_id == kontakt.kontakt_id
)
)
) {
kontakteArray = kontakteArray.concat(
this.data.kontakte
.filter((kontakt) => {
return kontakt.zustellung;
})
.map((kon) => {
return kon.kontakt_id;
})
);
}
return [...new Set(kontakteArray)];
},
},
computed: {
filteredEditData() {
if (!this.data) {
return;
}
return {
view: null,
data: {
Personen_Informationen: {
title: this.$p.t("profil", "personenInformationen"),
topic: "Personen_informationen",
view: null,
data: {
vorname: {
title: this.$p.t("person", "vorname"),
topic: this.profilUpdateTopic?.["Vorname"],
view: "TextInputDokument",
withFiles: true,
data: {
titel: "vorname",
value: this.data.vorname,
},
},
nachname: {
title: this.$p.t("person", "nachname"),
topic: this.profilUpdateTopic?.["Nachname"],
view: "TextInputDokument",
withFiles: true,
data: {
titel: "nachname",
value: this.data.nachname,
},
},
titel: {
title: this.$p.t("global", "titel"),
topic: this.profilUpdateTopic?.["Titel"],
view: "TextInputDokument",
withFiles: true,
data: {
titel: "titel",
value: this.data.titel,
},
},
postnomen: {
title: this.$p.t("profil", "postnomen"),
topic: this.profilUpdateTopic?.["Postnomen"],
view: "TextInputDokument",
withFiles: true,
data: {
titel: "postnomen",
value: this.data.postnomen,
},
},
},
},
Private_Kontakte: {
title: this.$p.t("profil", "privateKontakte"),
topic: this.profilUpdateTopic?.["Private Kontakte"],
data: this.data.kontakte
?.filter((item) => {
// excludes all contacts that are already used in pending profil update requests
return !this.data.profilUpdates?.some(
(update) =>
update.status === this.profilUpdateStates["Pending"] &&
update.requested_change?.kontakt_id === item.kontakt_id
);
})
.map((kontakt) => {
return {
listview: "Kontakt",
view: "EditKontakt",
data: kontakt,
};
}),
},
Private_Adressen: {
title: this.$p.t("profil", "privateAdressen"),
topic: this.profilUpdateTopic?.["Private Adressen"],
data: this.data.adressen
?.filter((item) => {
return !this.data.profilUpdates?.some((update) => {
return (
update.status === this.profilUpdateStates["Pending"] &&
update.requested_change?.adresse_id == item.adresse_id
);
});
})
.map((adresse) => {
return {
listview: "Adresse",
view: "EditAdresse",
data: adresse,
};
}),
},
},
};
},
},
watch: {
loading: function (newValue) {
if (newValue) {
this.$refs.loadingModalRef.show();
} else {
this.$refs.loadingModalRef.hide();
}
},
uid (newVal, oldVal) {
this.load()
}
},
created() {
this.load()
},
template: `
<div>
<div v-if="notFoundUID">
<h3>Es wurde keine Person mit der UID {{this.notFoundUID}} gefunden</h3>
</div>
<div v-else>
<loading ref="loadingModalRef" :timeout="0"></loading>
<component :is="view" :data="data" :editData="filteredEditData" ></component>
</div>
</div>`,
}
export default Profil
@@ -0,0 +1,63 @@
export default {
props:{
data:Object,
view:String,
withZustelladresse:{
type:Boolean,
default:false,
},
},
data(){
return{}
},
created(){
},
template:/*html*/`
<div class="gy-2 row justify-content-center align-items-center">
<!-- column 1 in the address row -->
<div class="col-1 text-center">
<i class="fa fa-location-dot fa-lg fhc-link-color" ></i>
</div>
<div class="col-11 col-sm-8 col-xl-11 col-xxl-8 order-1">
<div class="form-underline ">
<div class="form-underline-titel">{{$p.t('person','strasse')}}</div>
<span class="form-underline-content">{{data.strasse}} </span>
</div>
</div>
<!-- column 2 in the address row -->
<div class="offset-1 offset-sm-0 offset-xl-1 offset-xxl-0 order-2 order-sm-4 order-xl-2 order-xxl-4 col-11 col-sm-5 col-xl-11 col-xxl-5">
<div class="form-underline ">
<div class="form-underline-titel">{{$p.t('global','typ')}}</div>
<span class="form-underline-content">{{data.typ}} </span>
</div>
</div>
<div class="offset-1 order-3 order-sm-3 col-11 col-sm-6 col-xl-7 col-xxl-6">
<div class="form-underline ">
<div class="form-underline-titel">{{$p.t('person','ort')}}</div>
<span class="form-underline-content">{{data.ort}} </span>
</div>
</div>
<div class=" offset-1 offset-sm-0 order-4 order-sm-2 order-xl-4 order-xxl-2 col-11 col-sm-3 col-xl-4 col-xxl-3">
<div class="form-underline">
<div class="form-underline-titel">{{$p.t('person','plz')}}</div>
<span class="form-underline-content">{{data.plz}} </span>
</div>
</div>
<div v-if="withZustelladresse" class="order-5 offset-1 col-11">
<div class="form-underline">
<div class="form-underline-titel">{{$p.t('person','zustelladresse')}}</div>
<div class="ms-2 form-check ">
<input class="form-check-input" type="checkbox" @click.prevent :checked="data.zustelladresse" >
</div>
</div>
</div>
</div>
`
};
@@ -0,0 +1,215 @@
import EditProfil from "../ProfilModal/EditProfil.js";
import ApiProfil from '../../../../api/factory/profil.js';
import ApiProfilUpdate from '../../../../api/factory/profilUpdate.js';
//? EditProfil is the modal used to edit the profil updates
export default {
components: {EditProfil},
props: {
data: {
type: Object,
},
},
inject: [
"getZustellkontakteCount",
"getZustelladressenCount",
"profilUpdateStates",
"profilUpdateTopic",
],
emits: ["fetchUpdates"],
data() {
return {
showUpdateModal: false,
content: null,
editProfilTitle: this.$p.t("profil", "profilBearbeiten"),
};
},
methods: {
hideEditProfilModal: function () {
//? checks the editModal component property result, if the user made a successful request or not
if (this.$refs.updateEditModal.result) {
this.$emit("fetchUpdates");
} else {
// when modal was closed without submitting request
}
this.showUpdateModal = false;
},
async showEditProfilModal(updateRequest) {
let view = this.getView(updateRequest.topic, updateRequest.status);
let data = null;
let content = null;
let files = null;
let withFiles = false;
if (view === "TextInputDokument") {
data = {
titel: updateRequest.topic,
value: updateRequest.requested_change.value,
};
const filesFromDatabase =
await this.$api
.call(ApiProfilUpdate.getProfilRequestFiles(
updateRequest.profil_update_id
))
.then((res) => {
return res.data;
});
files = filesFromDatabase;
if (files) {
withFiles = true;
}
} else {
data = updateRequest.requested_change;
}
content = {
updateID: updateRequest.profil_update_id,
view: view,
data: data,
withFiles: withFiles,
topic: updateRequest.topic,
files: files,
};
if (view === "EditAdresse") {
const isMitarbeiter = await this.$api.call(ApiProfil.isMitarbeiter(updateRequest.uid)).then((res) => res.data);
if (isMitarbeiter) {
content["isMitarbeiter"] = isMitarbeiter;
}
const filesFromDatabase =
await this.$api
.call(ApiProfilUpdate.getProfilRequestFiles(
updateRequest.profil_update_id
))
.then((res) => {
return res.data;
});
files = filesFromDatabase;
content["files"] = files;
}
//? adds the status information if the profil update request was rejected or accepted
if (updateRequest.status !== this.profilUpdateStates["Pending"]) {
content["status"] = updateRequest.status;
content["status_message"] = updateRequest.status_message;
content["status_timestamp"] = updateRequest.status_timestamp;
}
//? update data of the reactive content
this.content = content;
this.editProfilTitle = updateRequest.topic;
//? only show the popup if also the right content is available
if (content) {
this.showUpdateModal = true;
// after a state change, wait for the DOM updates to complete
Vue.nextTick(() => {
this.$refs.updateEditModal.show();
});
}
},
deleteRequest: function (item) {
this.$api
.call(ApiProfilUpdate.deleteProfilRequest(item.profil_update_id))
.then((res) => {
if (res.data.error) {
//? open alert
console.error("error happened", res.data);
} else {
this.$emit("fetchUpdates");
}
});
},
getView: function (topic, status) {
if (!(status === this.profilUpdateStates["Pending"])) {
return "Status";
}
switch (topic) {
case this.profilUpdateTopic["Private Kontakte"]:
return "EditKontakt";
case this.profilUpdateTopic["Add Kontakt"]:
return "EditKontakt";
case this.profilUpdateTopic["Delete Kontakt"]:
return "Kontakt";
case this.profilUpdateTopic["Private Adressen"]:
return "EditAdresse";
case this.profilUpdateTopic["Add Adresse"]:
return "EditAdresse";
case this.profilUpdateTopic["Delete Adresse"]:
return "Adresse";
default:
return "TextInputDokument";
}
},
},
created() {
},
computed: {},
template: /*html*/ `
<div class="card">
<edit-profil v-if="showUpdateModal" ref="updateEditModal" @hideBsModal="hideEditProfilModal" :value="content" :titel="editProfilTitle"></edit-profil>
<div class="card-header">{{$p.t('profilUpdate','profilUpdates')}}</div>
<div class="card-body">
<div class="table-responsive text-nowrap">
<table class="m-0 table table-hover align-middle">
<thead>
<tr>
<th scope="col">{{$p.t('profilUpdate','topic')}}</th>
<th scope="col">{{$p.t('global','status')}}</th>
<th scope="col">{{$p.t('global','datum')}}</th>
<th class="text-center" scope="col">{{$p.t('ui','aktion')}}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in data" :style="item.status==profilUpdateStates['Accepted']?'background-color:lightgreen':item.status===profilUpdateStates['Rejected']?'background-color:lightcoral':''">
<td class="align-middle text-wrap ">{{item.topic}}</td>
<td class="align-middle " >{{item.status}}</td>
<td class="align-middle">{{item.status_timestamp?item.status_timestamp:item.insertamum}}</td>
<template v-if="item.status === profilUpdateStates['Pending']">
<td>
<div class="d-flex flex-row justify-content-evenly">
<template v-if="item.topic.toLowerCase().includes('delete')">
<div class="align-middle text-center"><i role="button" @click="showEditProfilModal(item)" class="fa fa-eye"></i></div>
</template>
<template v-else >
<div class="align-middle text-center" ><i @click="showEditProfilModal(item)" role="button" class="fa fa-edit fhc-primary-color"></i></div>
</template>
<div class="align-middle text-center"><i role="button" @click="deleteRequest(item)" class="text-danger fa fa-trash"></i></div>
</div>
</td>
</template>
<template v-else>
<td class="align-middle text-center">
<div class="d-flex flex-row justify-content-evenly">
<i role="button" @click="showEditProfilModal(item)" class="fa fa-eye"></i>
</div>
</td>
</template>
</tr>
</tbody>
</table>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,16 @@
export default {
props: {
data: {
type: String,
}
},
data() {
return {}
},
template: /*html*/`
<div class="card">
<div class="card-body">
<span >{{$p.t('profil','fhAusweisStatus',[data])}}</span>
</div>
</div>`,
}
@@ -0,0 +1,41 @@
export default{
props:{
view:String,
data:Object,
},
data(){
return {
}
},
created(){
},
template:/*html*/`
<template v-if="data.kontakt">
<div class="gy-2 row align-items-center justify-content-center">
<div class="col-1 text-center" >
<i class="fa-solid fhc-link-color" :class="{...(data.kontakt.includes('@')?{'fa-envelope':true}:{'fa-phone':true})}" ></i>
</div>
<div :class="{...(data.anmerkung? {'col-11':true, 'col-md-6':true, 'col-xl-11':true, 'col-xxl-6':true} : {'col-10':true, 'col-xl-9':true, 'col-xxl-10':true})}">
<!-- rendering KONTAKT emails -->
<div class="form-underline ">
<div class="form-underline-titel">{{$p.t('profil',data.kontakttyp.toUpperCase())}}</div>
<a v-if="data.kontakt.includes('@')" role="link" :aria-disabled="view?true:false" :href="!view?('mailto:'+data.kontakt):null" class="form-underline-content">{{data.kontakt}} </a>
<a v-else role="link" :aria-disabled="view?true:false" :href="!view?('tel:'+data.kontakt):null" class="form-underline-content">{{data.kontakt}} </a>
</div>
</div>
<div v-if="data?.anmerkung" class="offset-1 offset-md-0 offset-xl-1 offset-xxl-0 order-2 order-sm-1 col-10 col-md-4 col-xl-9 col-xxl-4 ">
<div class="form-underline ">
<div class="form-underline-titel">{{$p.t('global','anmerkung')}}</div>
<span class="form-underline-content">{{data.anmerkung}} </span>
</div>
</div>
<div class="text-center col-1 col-sm-1 order-2 order-lg-1 col-xl-2 col-xxl-1 allign-middle">
<i v-if="data.zustellung" class="fa-solid fa-check"></i>
<i v-else="data.zustellung" class="fa-solid fa-xmark"></i>
</div>
</div>
</template>
`,
};
@@ -0,0 +1,36 @@
export default {
props: {
data: Object,
title: { type: String },
},
data() {
return {};
},
created(){
},
template: /*html*/`
<template v-if="Array.isArray(data) && data.length >0">
<div class="card">
<div class="card-header">
{{title}}
</div>
<div class="card-body">
<h4 class="card-title">{{$p.t('profil','mailverteilerMitglied')}}</h4>
<div class="card-text row text-break mb-2" v-for="verteiler in data">
<div class="col-12 ">
<div class="row">
<div class="col-1 ">
<i class="fa-solid fa-envelope fhc-link-color" ></i>
</div>
<div class="col">
<a class="fhc-link-color" :href="verteiler.mailto"><b>{{verteiler.gruppe_kurzbz}}</b></a>
</div>
</div>
</div>
<div class="col-11 offset-1 ">{{verteiler.beschreibung}}</div>
</div>
</div>
</div>
</template>`,
};
@@ -0,0 +1,40 @@
export default {
data() {
return {};
},
props: {
title: {
type: String,
required: true,
},
data: {
type: Array,
},
},
template: /*html*/ `
<div class="card ">
<div class="card-header">
{{title}}
</div>
<div class="card-body">
<!-- HIER SIND DIE EMAILS -->
<div class="gy-3 row justify-content-center ">
<div v-for="email in data" class="col-12 ">
<template v-if="email.email">
<div class="row align-items-center">
<div class="col-1 text-center">
<i class="fa-solid fa-envelope fhc-link-color" ></i>
</div>
<div class="col-11">
<div class="form-underline">
<div class="form-underline-titel">{{email?.type}}</div>
<a :href="'mailto:'+email?.email" class="form-underline-content">{{email?.email}} </a>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
</div>`,
};
@@ -0,0 +1,131 @@
import ApiProfil from '../../../../api/factory/profil.js';
import ImageUpload from '../../Profil/ProfilModal/EditProfilComponents/ImageUpload.js';
export default {
props: {
title: {
type: String,
},
data: {
type: Object,
},
fotoStatus:{
type: Boolean,
default: true
}
},
components:{
ImageUpload,
},
data() {
return {
FotoSperre: this.data.foto_sperre,
};
},
emits: ["showEditProfilModal"],
inject:["isEditable"],
methods: {
showModal(){
this.$refs.imageUpload.show();
},
sperre_foto_function() {
//TODO: refactor
if (!this.data) {
return;
}
this.$api
.call(ApiProfil.fotoSperre(!this.FotoSperre))
.then(res => {
this.FotoSperre = res.data.foto_sperre;
});
}
},
computed: {
get_image_base64_src: function () {
if (!this.data.foto) {
return "";
}
return "data:image/jpeg;base64," + this.data.foto;
},
name: function () {
return {vorname: this.data.Vorname, nachname: this.data.Nachname};
},
profilInfo: function () {
let res = {};
let notIncludedProperties = [
"Vorname",
"Nachname",
"foto_sperre",
"foto",
];
Object.keys(this.data).forEach((key) => {
if (!notIncludedProperties.includes(key)) {
res[key] = this.data[key];
}
});
return res;
},
},
template: /*html*/ `
<div class="card h-100">
<image-upload ref="imageUpload" :titel="$p.t('profilUpdate','profilBild')"></image-upload>
<div class="card-header">
<div class="row">
<div v-if="isEditable" @click="$emit('showEditProfilModal','Personen_Informationen')" class="col-auto" type="button" :title="$p.t('profilUpdate','profilBearbeiten')">
<i class="fa fa-edit"></i>
</div>
<div class="col">
<span>{{title}}</span>
</div>
</div>
</div>
<div class="card-body">
<div class="gy-3 row justify-content-center align-items-center">
<!-- SQUEEZING THE IMAGE INSIDE THE FIRST INFORMATION COLUMN -->
<!-- START OF THE FIRST ROW WITH THE PROFIL IMAGE -->
<div class="col-12 col-sm-6 mb-2">
<div class="row justify-content-center">
<div class="col-auto profil-image" style="position:relative">
<img alt="profile picture" class=" img-thumbnail " style=" max-height:150px; " :src="get_image_base64_src"/>
<!-- LOCKING IMAGE FUNCTIONALITY -->
<div v-if="isEditable" role="button" type="button" :title="FotoSperre?$p.t('profil','fotoSperren'):$p.t('profil','fotoEntsperren')" @click.prevent="sperre_foto_function" class="image-lock">
<i :class="{'fa':true, ...(FotoSperre?{'fa-lock':true}:{'fa-lock-open':true})} "></i>
</div>
<div v-if="fotoStatus != null && !fotoStatus" role="button" type="button" :title="$p.t('profil','fotoHochladen')" @click.prevent="showModal" class="image-upload">
<i class="fa fa-upload"></i>
</div>
</div>
</div>
<!-- END OF THE ROW WITH THE IMAGE -->
</div>
<!-- END OF SQUEEZE -->
<!-- COLUMNS WITH MULTIPLE ROWS NEXT TO PROFIL PICTURE -->
<div class="col-12 col-sm-6">
<div class="row gy-4">
<div class="col-12">
<div class="form-underline ">
<div class="form-underline-titel">{{$p.t('profilUpdate','vorname')}}</div>
<span class="form-underline-content">{{name.vorname}} </span>
</div>
</div>
<div class="col-12">
<div class="form-underline ">
<div class="form-underline-titel">{{$p.t('profilUpdate','nachname')}}</div>
<span class="form-underline-content">{{name.nachname}} </span>
</div>
</div>
</div>
</div>
<div v-for="(wert,bez) in profilInfo" class="col-md-6 col-sm-12">
<div class="form-underline">
<div class="form-underline-titel">{{$p.t('profil',bez)}}</div>
<span class="form-underline-content">{{wert?wert:'-'}} </span>
</div>
</div>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,53 @@
export default {
//TODO: To be implemented
props: {
data: {
type: String,
},
title: {
type: String,
required: true,
},
mobile: {
type: Boolean,
default: false,
},
},
methods: {
hideCollapse: function () {
this.collapseOpen = false;
},
showCollapse: function () {
this.collapseOpen = true;
},
},
data() {
return {
collapseOpen: false,
};
},
template: /*html*/ `
<div class="card">
<template v-if="mobile">
<button class="btn btn-outline-primary" data-bs-toggle="collapse" data-bs-target="#quickLinks" :aria-expanded="collapseOpen" aria-controls="quickLinks" >
{{title}}
<i class="fa " :class="collapseOpen?'fa-chevron-up':'fa-chevron-down'"></i>
</button>
<div @[\`show.bs.collapse\`]="collapseOpen=true;" @[\`hide.bs.collapse\`]="collapseOpen=false;" class="mt-1 collapse" id="quickLinks">
<div class="list-group">
<a href="#" class="list-group-item list-group-item-action">{{$p.t('profil','zeitwuensche')}}</a>
<a href="#" class="list-group-item list-group-item-action">{{$p.t('profil','lehrveranstaltungen')}}</a>
<a href="#" class="list-group-item list-group-item-action ">{{$p.t('profil','zeitsperren')}}</a>
</div>
</div>
</template>
<template v-else>
<div class="card-header">{{title}}</div>
<div class="card-body">
<a style="text-decoration:none" class="my-1 d-block" href="#">{{$p.t('profil','zeitwuensche')}}</a>
<a style="text-decoration:none" class="my-1 d-block" href="#">{{$p.t('profil','lehrveranstaltungen')}}</a>
<a style="text-decoration:none" class="my-1 d-block" href="#">{{$p.t('profil','zeitsperren')}}</a>
</div>
</template>
</div>`,
};
@@ -0,0 +1,79 @@
export default {
data() {
return {}
},
props: {
data: {
type: Object,
},
title: {
type: String,
}
},
inject: [
'studiengang_kz', // inject info that should not be displayed
],
computed: {
getLinkGruppeListe() {
return this.data.gruppe?.value && this.data.verband?.value && this.data.semester?.value ? FHC_JS_DATA_STORAGE_OBJECT.app_root
+ 'cis/private/stud_in_grp.php?kz='+this.studiengang_kz+'&sem=' + this.data.semester.value
+ '&verband=' + this.data.verband.value + '&grp=' + this.data.gruppe.value : ''
},
getLinkVerbandListe() {
return this.data.verband?.value && this.data.semester?.value ? FHC_JS_DATA_STORAGE_OBJECT.app_root
+ 'cis/private/stud_in_grp.php?kz='+this.studiengang_kz+'&sem=' + this.data.semester.value
+ '&verband=' + this.data.verband.value : ''
},
getLinkSemesterListe() {
return this.data.semester?.value ? FHC_JS_DATA_STORAGE_OBJECT.app_root
+ 'cis/private/stud_in_grp.php?kz='+this.studiengang_kz+'&sem=' + this.data.semester.value : ''
}
},
created() {
//TODO: check if data.Telefon is a valid telefon number to call before using it as a tel: link
},
template: `
<div class="card">
<div class="card-header">{{title}}</div>
<div class="card-body">
<div class="gy-3 row">
<div v-for="(entry, key) in data" class="col-md-6 col-sm-12 ">
<div class="form-underline">
<div class="form-underline-titel">{{entry.label }}</div>
<!-- print Telefon link -->
<a v-if="key == 'telefon'" :href="entry.value ?'tel:'+entry.value:null" :class="{'form-underline-content':true,'text-decoration-none':!entry.value,'text-body':!entry.value}">{{entry.value ?? '-'}}</a>
<!-- print semester link -->
<span v-else-if="key == 'semester' && entry.value" class="form-underline-content">
{{ entry.value }}
<a :aria-label="$p.t('profil','semesterLink')" class="ms-auto mb-2" target="_blank" :href="getLinkSemesterListe">
<i aria-hidden="true" class="fa fa-arrow-up-right-from-square me-1 fhc-link-color"></i>
</a>
</span>
<!-- print verband link -->
<span v-else-if="key =='verband' && entry.value" class="form-underline-content">
{{ entry.value }}
<a :aria-label="$p.t('profil','verbandLink')" class="ms-auto mb-2" target="_blank" :href="getLinkVerbandListe">
<i aria-hidden="true" class="fa fa-arrow-up-right-from-square me-1 fhc-link-color"></i>
</a>
</span>
<!-- print gruppe link -->
<span v-else-if="key == 'gruppe' && entry.value" class="form-underline-content">
{{ entry.value }}
<a :aria-label="$p.t('profil','gruppenLink')" class="ms-auto mb-2" target="_blank" :href="getLinkGruppeListe">
<i aria-hidden="true" class="fa fa-arrow-up-right-from-square me-1 fhc-link-color"></i>
</a>
</span>
<!-- else print information -->
<span v-else class="form-underline-content">{{ entry.value ?? '-'}}</span>
</div>
</div>
</div>
</div>
</div>`
};
@@ -0,0 +1,212 @@
import BsModal from "../../../Bootstrap/Modal.js";
import Alert from "../../../Bootstrap/Alert.js";
import EditProfilSelect from "./EditProfilSelect.js";
import Loader from "../../../Loader.js";
import ApiProfilUpdate from '../../../../api/factory/profilUpdate.js';
export default {
components: {
BsModal,
Alert,
EditProfilSelect,
Loader,
},
mixins: [BsModal],
props: {
value: Object,
titel: String,
zustelladressenCount: Function,
zustellkontakteCount: Function,
/*
* NOTE(chris):
* Hack to expose in "emits" declared events to $props which we use
* in the v-bind directive to forward all events.
* @see: https://github.com/vuejs/core/issues/3432
*/
onHideBsModal: Function,
onHiddenBsModal: Function,
onHidePreventedBsModal: Function,
onShowBsModal: Function,
onShownBsModal: Function,
},
data() {
return {
topic: null,
profilUpdate: null,
editData: this.value,
fileID: null,
breadcrumb: null,
loading: false,
result: false,
info: null,
};
},
inject: ["setLoading"],
provide() {
return {
updateFileID: this.updateFileIDFunction,
};
},
methods: {
updateFileIDFunction: function (newFileID) {
this.fileID = newFileID;
},
handleFailedError: function (err) {
console.error(err);
this.loading = false;
this.setLoading(false);
this.result = false;
this.hide();
},
async submitProfilChange() {
//? check if data is valid before making a request
if (this.topic && this.profilUpdate) {
//? if profil update contains any attachment
if (this.fileID) {
const fileData = await this.uploadFiles(this.fileID);
this.fileID = fileData ? fileData : null;
}
//? inserts new row in public.tbl_cis_profil_update
//* calls the update api call if an update field is present in the data that was passed to the modal
const handleApiResponse = (res) => {
//? toggles the loading to false and closes the loading modal
if (res.data.error) {
this.result = false;
Alert.popup(
"Ein Fehler ist aufgetreten: " + JSON.stringify(res.data.retval)
);
} else {
this.result = true;
Alert.popup(
"Ihre Anfrage wurde erfolgreich gesendet. Bitte warten Sie, während sich das Team um Ihre Anfrage kümmert."
);
}
};
//* v-show on EditProfil modal binded to this.loading
//? hides the EditProfil modal and shows the loading modal by calling a callback that was passed as prop from the parent component
this.loading = true;
this.setLoading(true);
//? if an updateID is present, updateProfilRequest is called, else insertProfilRequest is called
this.editData.updateID ?
this.$api
.call(ApiProfilUpdate.updateProfilRequest(
this.topic,
this.profilUpdate,
this.editData.updateID,
this.fileID ? this.fileID[0] : null
))
.then((res) => {
handleApiResponse(res);
})
.catch((err) => this.$fhcAlert.handleSystemError)
.finally(() => {
this.loading = false;
this.setLoading(false);
this.hide();
})
:
this.$api
.call(ApiProfilUpdate.insertProfilRequest(
this.topic,
this.profilUpdate,
this.fileID ? this.fileID[0] : null
))
.then((res) => {
handleApiResponse(res);
})
.catch((err) => this.$fhcAlert.handleSystemError)
.finally(() => {
this.loading = false;
this.setLoading(false);
this.hide();
});
}
},
uploadFiles: async function (files) {
if (files[0].type !== "application/x.fhc-dms+json") {
let formData = new FormData();
formData.append("files[]", files[0]);
const result = this.editData.updateID
? //? updating old attachment by replacing
//* second parameter of api request insertFile checks if the file has to be replaced or not
await this.$api
.call(ApiProfilUpdate.insertFile(
formData,
this.editData.updateID
))
.then((res) => {
return res.data?.map((file) => file.dms_id);
})
: //? fresh insert of new attachment
await this.$api
.call(ApiProfilUpdate.insertFile(formData))
.then((res) => {
return res.data?.map((file) => file.dms_id);
});
return result;
} else {
//? attachment hasn't been replaced
return false;
}
},
},
computed: {
showFooter: function () {
switch (this.value.view) {
case 'Status':
return false;
case 'Kontakt':
return false;
case 'Adresse':
return false;
default:
return true;
}
},
},
created() {
if (this.editData.topic) {
//? if the topic was passed through the prop add it to the component
this.topic = this.editData.topic;
}
},
mounted() {
this.modal = this.$refs.modalContainer.modal;
},
popup(options) {
BsModal.popup.bind(this);
return BsModal.popup(null, options);
},
template: /*html*/ `
<bs-modal v-show="!loading" ref="modalContainer" body-class="" v-bind="$props" dialog-class="modal-lg" class="bootstrap-alert" :backdrop="false">
<template v-if="titel" v-slot:title>{{titel}}</template>
<template v-slot:default>
<div>
<nav aria-label="breadcrumb" class="ps-2">
<ol class="breadcrumb">
<li class="breadcrumb-item" v-for="element in breadcrumb">{{element}}</li>
</ol>
</nav>
<edit-profil-select @submit="submitProfilChange" v-model:breadcrumb="breadcrumb" v-model:topic="topic" v-model:profilUpdate="profilUpdate" ariaLabel="select" :list="editData"></edit-profil-select>
</div>
</template>
<!-- optional footer -->
<template v-slot:footer v-if="showFooter">
<loader ref="loaderRef" :timeout="0"></loader>
<button class="btn btn-outline-danger " @click="hide">{{$p.t('ui','cancel')}}</button>
<button :disabled="!profilUpdate" @click="submitProfilChange" role="button" class="btn btn-primary">{{$p.t('ui','senden')}}</button>
</template>
<!-- end of optional footer -->
</bs-modal>`,
};
@@ -0,0 +1,267 @@
import Dms from "../../../../Form/Upload/Dms.js";
import ApiProfil from '../../../../../api/factory/profil.js';
export default {
components: {
AutoComplete: primevue.autocomplete,
Dms: Dms
},
props: {
data: Object,
isMitarbeiter: {
type: Boolean,
default: false,
},
files: {
type: Array,
default: []
},
},
inject: ["getZustelladressenCount", "updateFileID"],
data() {
return {
gemeinden: [],
ortschaftnamen: [],
selectedNation: null,
nationenList: [],
originalValue: null,
zustellAdressenCount: null,
dmsData: [],
fileschanged: false
};
},
watch: {
"data.gemeinde": function (newValue, oldValue) {
this.$emit("profilUpdate", this.isChanged ? this.data : null);
},
"data.ort": function (newValue, oldValue) {
this.$emit("profilUpdate", this.isChanged ? this.data : null);
},
},
methods: {
autocompleteSearchGemeinden: function (event) {
this.gemeinden = this.gemeinden.map((gemeinde) => gemeinde);
},
autocompleteSearchOrtschaftsnamen: function (event) {
this.ortschaftnamen = this.ortschaftnamen.map((ortschaft) => ortschaft);
},
getGemeinde: function () {
//? only query the gemeinde is the nation is Austria and the PLZ is greater than 999 and less than 32000
if (
this.data.nation &&
this.data.nation === "A" &&
this.data.plz &&
this.data.plz > 999 &&
this.data.plz < 32000
) {
this.$api
.call(ApiProfil.getGemeinden(this.data.nation, this.data.plz))
.then((res) => {
if (res.data.length) {
this.gemeinden = [
...new Set(
res.data.map((element) => {
return element.name;
})
),
];
this.ortschaftnamen = [
...new Set(
res.data.map((element) => {
return element.ortschaftsname;
})
),
];
}
});
} else {
this.gemeinden = [];
}
},
updateValue: function (event, bind) {
//? sets the value of a property to null when an empty string is entered to keep the isChanged function valid
if (bind === "zustelladresse") {
this.data[bind] = event.target.checked;
} else if(bind === 'files') {
if(this.dmsData.length > 0 && this.dmsData[0].type !== 'application/x.fhc-dms+json') {
this.fileschanged = true;
}
this.updateFileID(this.dmsData);
} else {
this.data[bind] = event.target.value === "" ? null : event.target.value;
}
this.$emit("profilUpdate", this.isChanged ? this.data : null);
// update the zustellAdressen count
this.zustellAdressenCount = this.getZustelladressenCount();
},
deleteDmsData: function() {
this.dmsData = [];
this.updateValue(null, 'files');
}
},
computed: {
showZustellAdressenWarning: function () {
// if the address was already a zustellungsadresse when editing the address, then the warning will not be shown and the zustellungsadresse will just be overwritten
if (JSON.parse(this.originalValue).zustelladresse){
return false;
}
// if zustellAdressenCount is not 0 and the own kontakt has the flag zustellung set to true
if (!this.zustellAdressenCount.includes(this.data.adresse_id)) {
return this.data.zustelladresse && this.zustellAdressenCount.length;
}
return this.zustellAdressenCount.length >= 2 && this.data.zustelladresse;
},
isChanged: function () {
if (
!this.data.strasse ||
!this.data.plz ||
!this.data.ort ||
!this.data.typ ||
this.dmsData.length === 0
) {
return false;
}
const datachanged = this.originalValue !== JSON.stringify(this.data);
return datachanged || this.fileschanged;
},
},
created() {
// get all available nationen
this.$api
.call(ApiProfil.getAllNationen())
.then(res => {
this.nationenList = res.data;
this.getGemeinde();
});
this.originalValue = JSON.stringify(this.data);
this.zustellAdressenCount = this.getZustelladressenCount();
},
mounted() {
if (this.files) {
this.dmsData = this.files;
}
},
template: /*html*/ `
<div class="gy-3 row justify-content-center align-items-center">
<!-- warning message for too many zustellungs Adressen -->
<div v-if="showZustellAdressenWarning" class="col-12 ">
<div class="card bg-danger mx-2">
<div class="card-body text-white ">
<span>{{$p.t('profilUpdate','zustell_adressen_warning')}}</span>
</div>
</div>
</div>
<!-- End of warning -->
<div class="col-12 ">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" @change="updateValue($event,'zustelladresse')" :checked="data.zustelladresse" id="flexCheckDefault">
<label class="form-check-label" for="flexCheckDefault">
{{$p.t('person','zustelladresse')}}
</label>
</div>
</div>
<!-- NATION -->
<div class="col-8">
<div class="form-underline ">
<div class="form-underline-titel">{{$p.t('person','nation')}}*</div>
<select :value="data.nation" @change="updateValue($event,'nation')" @change="getGemeinde" class="form-select" aria-label="Select Kontakttyp">
<option selected></option>
<option :value="nation.code" v-for="nation in nationenList">{{nation.langtext}}</option>
</select>
</div>
</div>
<!-- PLZ -->
<div class=" col-4">
<div class="form-underline">
<div class="form-underline-titel">{{$p.t('person','plz')}}*</div>
<input class="form-control" :value="data.plz" :aria-label="$p.t('person','plz')" :title="$p.t('person','plz')" @input="updateValue($event,'plz')" @input="getGemeinde" :placeholder="data.plz">
</div>
</div>
<!-- GEMEINDE -->
<div class="col-lg-6">
<div class="form-underline ">
<div class="form-underline-titel">{{$p.t('person','gemeinde')}}*</div>
<auto-complete :aria-label="$p.t('person','gemeinde')" class="w-100" v-model="data.gemeinde" dropdown :forceSelection="data.nation ==='A'?true:false" :suggestions="gemeinden" @complete="autocompleteSearchGemeinden" ></auto-complete>
</div>
</div>
<!-- ORT -->
<div class="col-lg-6" >
<div class="form-underline ">
<div class="form-underline-titel">{{$p.t('person','ort')}}*</div>
<auto-complete :aria-label="$p.t('person','ort')" class="w-100" v-model="data.ort" dropdown :forceSelection="data.nation ==='A'?true:false" :suggestions="ortschaftnamen" @complete="autocompleteSearchOrtschaftsnamen" ></auto-complete>
</div>
</div>
<!-- STRASSE -->
<div class="col-lg-8">
<div class="form-underline ">
<div class="form-underline-titel">{{$p.t('person','strasse')}}*</div>
<input :aria-label="$p.t('person','strasse')" class="form-control" :value="data.strasse" @input="updateValue($event,'strasse')" :placeholder="data.strasse">
</div>
</div>
<!-- ADRESSEN TYP -->
<div class="col-lg-4">
<div class="form-underline">
<div class="form-underline-titel">{{$p.t('profilUpdate','kontaktTyp')}}*</div>
<select :value="data.typ" @change="updateValue($event,'typ')" class="form-select" aria-label="Select Kontakttyp">
<option selected></option>
<option value="Nebenwohnsitz">{{$p.t('profilUpdate','nebenwohnsitz')}}</option>
<option value="Hauptwohnsitz">{{$p.t('profilUpdate','hauptwohnsitz')}}</option>
<option v-if="isMitarbeiter" value="Homeoffice">{{$p.t('profilUpdate','homeoffice')}}</option>
<option v-if="isMitarbeiter" value="Rechnungsadresse">{{$p.t('profilUpdate','rechnungsadresse')}}</option>
</select>
</div>
</div>
<div class="row g-2">
<div class="col">
<div class="form-underline-titel">{{$p.t('profilUpdate','meldebestaetigung')}}*</div>
<dms
ref="update"
id="files"
name="files"
:multiple="false"
v-model="dmsData"
@update:model-value="updateValue($event,'files')"
></dms>
</div>
<div class="col-auto">
<div>&nbsp;</div>
<button
@click="deleteDmsData"
class="btn btn-danger"
:aria-label="$p.t('profilUpdate','deleteAttachment')"
:title="$p.t('profilUpdate','deleteAttachment')"
><i style="color:white" class="fa fa-trash" aria-hidden="true"></i></button>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,118 @@
export default {
props: {
data: Object,
},
data() {
return {
originalValue: null,
zustellKontakteCount: null,
};
},
inject: ["getZustellkontakteCount"],
methods: {
updateValue: function (event, bind) {
if (bind === "zustellung") {
this.data[bind] = event.target.checked;
} else {
//? sets the value of a property to null when an empty string is entered to keep the isChanged function valid
this.data[bind] = event.target.value === "" ? null : event.target.value;
}
this.$emit("profilUpdate", this.isChanged ? this.data : null);
this.zustellKontakteCount = this.getZustellkontakteCount();
},
},
computed: {
showZustellKontakteWarning: function () {
// if the kontakt is already a zustellungskontakt when the user is editing the kontakt, then no warning is shown and the zustellung will be overwritten
if (JSON.parse(this.originalValue).zustellung) {
return false;
}
// if zustellKontakteCount is not 0 and the own kontakt has the flag zustellung set to true
if (!this.zustellKontakteCount.includes(this.data.kontakt_id)) {
return this.data.zustellung && this.zustellKontakteCount.length;
}
return this.zustellKontakteCount.length >= 2 && this.data.zustellung;
},
isChanged: function () {
//? returns true if the original passed data object was changed
if (!this.data.kontakt || !this.data.kontakttyp) {
return false;
}
return JSON.stringify(this.data) !== this.originalValue;
},
},
created() {
this.originalValue = JSON.stringify(this.data);
this.zustellKontakteCount = this.getZustellkontakteCount();
},
template:
/*html*/
`
<div class="gy-3 row align-items-center justify-content-center">
<!-- warning message for too many zustellungs Kontakte -->
<div v-if="showZustellKontakteWarning" class="col-12 ">
<div class="card bg-danger mx-2">
<div class="card-body text-white ">
<span>{{$p.t('profilUpdate','zustell_kontakte_warning')}}</span>
</div>
</div>
</div>
<!-- End of warning -->
<div v-if="!data.kontakt_id" class="col-12">
<div class="form-underline">
<div class="form-underline-titel">{{$p.t('profilUpdate','kontaktTyp')}}</div>
<select :value="data.kontakttyp" @change="updateValue($event,'kontakttyp')" class="form-select" aria-label="Select Kontakttyp">
<option selected></option>
<option value="email">{{$p.t('person','email')}}</option>
<option value="telefon">{{$p.t('person','telefon')}}</option>
<option value="notfallkontakt">{{$p.t('profilUpdate','notfallkontakt')}}</option>
<option value="mobil">{{$p.t('profilUpdate','mobiltelefonnummer')}}</option>
<option value="homepage">{{$p.t('profilUpdate','homepage')}}</option>
<option value="fax">{{$p.t('profilUpdate','faxnummer')}}</option>
</select>
</div>
</div>
<div class="col-12">
<!-- rendering KONTAKT emails -->
<div class="form-underline">
<div class="form-underline-titel">{{data.kontakttyp?data.kontakttyp:$p.t('global','kontakt')}}</div>
<input :aria-label="data.kontakttyp" :title="data.kontakttyp" :disabled="data.kontakttyp?false:true" class="form-control" :value="data.kontakt" @input="updateValue($event,'kontakt')" :placeholder="data.kontakt">
</div>
</div>
<div class="col-12">
<div class="form-underline">
<div class="form-underline-titel">{{$p.t('global','anmerkung')}}</div>
<input :aria-label="$p.t('global','anmerkung')" :title="$p.t('global','anmerkung')" class="form-control" :value="data.anmerkung" @input="updateValue($event,'anmerkung')" :placeholder="data.anmerkung">
</div>
</div>
<div class="d-flex flex-row justify-content-start col-12 allign-middle">
<span style="opacity: 0.65; font-size: .85rem; " class="px-2">{{$p.t('profilUpdate','zustellungsKontakt')}}</span>
<input :aria-label="$p.t('profilUpdate','zustellungsKontakt')" :title="$p.t('profilUpdate','zustellungsKontakt')" class="form-check-input " type="checkbox" :checked="data.zustellung" @change="updateValue($event,'zustellung')" id="flexCheckDefault">
</div>
</div>
`,
};
@@ -0,0 +1,82 @@
import Dms from "../../../../Form/Upload/Dms.js";
import BsModal from "../../../../Bootstrap/Modal.js";
import ApiProfilUpdate from '../../../../../api/factory/profilUpdate.js';
export default {
data() {
return {
dmsData: [],
};
},
components: {
Dms,
BsModal,
},
mixins: [BsModal],
props: {
titel: {
type: Object,
},
files: {
type: Array,
},
updateID: {
type: Boolean,
},
onHideBsModal: Function,
onHiddenBsModal: Function,
onHidePreventedBsModal: Function,
onShowBsModal: Function,
onShownBsModal: Function,
},
methods:{
async uploadImage(){
if(this.dmsData){
let formData = new FormData();
formData.append("files[]", this.dmsData[0]);
await this.$api
.call(ApiProfilUpdate.updateProfilbild(formData))
.then((res) => {
this.$fhcAlert.alertSuccess(this.$p.t('global','hochgeladen'));
this.modal.hide();
});
}
}
},
mounted() {
this.modal = this.$refs.modalContainer.modal;
if (this.files) {
this.dmsData = this.files;
}
},
popup(options) {
BsModal.popup.bind(this);
return BsModal.popup(null, options);
},
template: /*html*/`
<bs-modal v-show="!loading" ref="modalContainer" v-bind="$props" body-class="" dialog-class="modal-lg" class="bootstrap-alert" :backdrop="false">
<template #title>
<p style="opacity:0.8" class="ms-2" v-if="!updateID">{{$p.t('profilUpdate','profilBildUpdateMessage',[titel])}}</p>
</template>
<template #default>
<div class="form-underline">
<div class="form-underline-titel">{{titel?titel:$p.t('global','titel')}}</div>
</div>
<div class="row gx-2">
<div class="col">
<dms ref="update" id="files" name="files" :multiple="false" v-model="dmsData" ></dms>
</div>
<div class="col-auto">
<button @click="dmsData=[]" class="btn btn-danger"><i style="color:white" class="fa fa-trash"></i></button>
</div>
</div>
<div class="d-flex" >
<button @click="uploadImage" class="btn fhc-primary-bg mt-4 text-light" style="margin-left:auto;">{{$p.t('global','upload')}}</button>
</div>
</template>
</bs-modal>
`,
};
@@ -0,0 +1,128 @@
import Adresse from "../../ProfilComponents/Adresse.js";
import Kontakt from "../../ProfilComponents/Kontakt.js";
import ApiProfilUpdate from '../../../../../api/factory/profilUpdate.js';
export default {
components: {
Adresse,
Kontakt,
},
inject: ["profilUpdateTopic"],
data() {
return {
files: null,
};
},
methods: {
getDocumentLink: function (dms_id) {
return (
FHC_JS_DATA_STORAGE_OBJECT.app_root +
FHC_JS_DATA_STORAGE_OBJECT.ci_router +
`/Cis/ProfilUpdate/show/${dms_id}`
);
},
},
computed: {
getComponentView: function () {
if (
this.topic == this.profilUpdateTopic["Private Adressen"] ||
this.topic == this.profilUpdateTopic["Add Adresse"] ||
this.topic == this.profilUpdateTopic["Delete Adresse"]
) {
return "Adresse";
} else if (
this.topic == this.profilUpdateTopic["Private Kontakte"] ||
this.topic == this.profilUpdateTopic["Add Kontakt"] ||
this.topic == this.profilUpdateTopic["Delete Kontakt"]
) {
return "Kontakt";
} else {
return "text_input";
}
},
cardHeader: function () {
if (
this.topic == this.profilUpdateTopic["Delete Addresse"] ||
this.topic == this.profilUpdateTopic["Delete Kontakt"]
) {
return "Delete";
} else if (
this.topic == this.profilUpdateTopic["Add Adresse"] ||
this.topic == this.profilUpdateTopic["Add Kontakt"]
) {
return "Add";
} else {
return "Update";
}
},
},
props: {
data: { type: Object },
view: { type: String },
status: { type: String },
status_message: { type: String },
status_timestamp: { type: String },
updateID: { type: Number },
topic: { type: String },
},
created() {
this.$api
.call(ApiProfilUpdate.getProfilRequestFiles(this.updateID))
.then((res) => {
this.files = res.data;
});
},
template: /*html*/ `
<div class="row">
<div class="col">
<div class="form-underline mb-2">
<div class="form-underline-titel">{{$p.t('global','status')}}</div>
<span class="form-underline-content">{{status}} </span>
</div>
</div>
<div class="col">
<div class="form-underline mb-2">
<div class="form-underline-titel">{{$p.t('global','datum')}}</div>
<span class="form-underline-content">{{status_timestamp}} </span>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div v-if="status_message" class="form-underline mb-2 ">
<div class="form-underline-titel">{{$p.t('profilUpdate','statusMessage')}}</div>
<textarea class="form-control" rows="4" disabled>{{status_message}} </textarea>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-header">
<i class="fa" :class="{'fa-trash':cardHeader==='Delete', 'fa-edit':cardHeader==='Update', 'fa-plus':cardHeader==='Add'}" ></i>
{{cardHeader}}
</div>
<div class="card-body">
<template v-if="getComponentView === 'text_input'">
<div class="form-underline mb-2">
<div class="form-underline-titel">{{topic}}</div>
<span class="form-underline-content">{{data.value}} </span>
</div>
</template>
<component v-else :is="getComponentView" :data="data"></component>
</div>
</div>
<div v-if="files?.length" class="card mt-4">
<div class="card-header">{{$p.t('profilUpdate','nachweisdokumente')}}</div>
<div class="card-body">
<a target="_blank" :href="getDocumentLink(file.dms_id)" v-for="file in files">{{file.name}}</a>
</div>
</div>
`,
};
@@ -0,0 +1,114 @@
import Dms from "../../../../Form/Upload/Dms.js";
export default {
data() {
return {
dmsData: [],
originalValue: null,
};
},
components: {
Dms,
},
props: {
data: {
type: Object,
},
withFiles: {
type: Boolean,
default: false,
},
files: {
type: Array,
},
updateID: {
type: Boolean,
},
},
inject:["updateFileID"],
computed: {
didFilesChange: function () {
this.updateFileID(this.dmsData);
let res = false;
//? case in which the profilRequest has already associated files
if(this.files){
Array.from(this.dmsData).forEach((file) => {
if (this.files.some((f) => f.name !== file.name)) {
res = true;
}
});
return !(this.dmsData.length == this.files.length) || res;
}
//? case in which the user creates a new profilRequest
else{
return Array.from(this.dmsData).length? true:false;
}
},
didDataChange: function(){
return JSON.stringify(this.data) !== this.originalValue;
},
isChanged: function () {
if (this.withFiles) {
if(this.updateID){
return (this.didDataChange || this.didFilesChange) && this.dmsData.length;
}
return this.didDataChange && this.didFilesChange;
}
return this.didDataChange
},
},
emits: ["profilUpdate"],
watch: {
//? watcher to trigger the event emit when a file was uploaded or removed
dmsData(value) {
this.emitChanges();
},
},
methods: {
stringifyFile(file) {
return JSON.stringify({
lastModified: file.lastModified,
lastModifiedDate: file.lastModifiedDate,
name: file.name,
size: file.size,
type: file.type
});
},
emitChanges: function () {
if (this.isChanged) {
this.$emit(
"profilUpdate", { value: this.data.value }
);
} else {
this.$emit("profilUpdate", null);
}
},
},
mounted() {
this.originalValue = JSON.stringify(Vue.toRaw(this.data));
if (this.files) {
this.dmsData = this.files;
}
},
template: /*html*/`
<p style="opacity:0.8" class="ms-2" v-if="withFiles && !updateID">{{$p.t('profilUpdate','profilUpdateInformationMessage',[data.titel])}}</p>
<div class="form-underline">
<div class="form-underline-titel">{{data.titel?data.titel:$p.t('global','titel')}}</div>
<input class="mb-2 form-control" @input="emitChanges" :aria-label="data.titel" :tooltip="data.titel" v-model="data.value" :placeholder="data.value">
<div class="row gx-2">
<div class="col">
<dms ref="update" v-if="withFiles" id="files" name="files" :multiple="false" v-model="dmsData" @update:model-value="didFilesChange" ></dms>
</div>
<div class="col-auto">
<button @click="dmsData=[]" class="btn btn-danger" :aria-label="$p.t('profilUpdate','deleteAttachment')" :title="$p.t('profilUpdate','deleteAttachment')" ><i style="color:white" class="fa fa-trash" aria-hidden="true"></i></button>
</div>
</div>
</div>
`,
};
@@ -0,0 +1,171 @@
import Kontakt from "../ProfilComponents/Kontakt.js";
import EditKontakt from "./EditProfilComponents/EditKontakt.js";
import Adresse from "../ProfilComponents/Adresse.js";
import EditAdresse from "./EditProfilComponents/EditAdresse.js";
import Status from "./EditProfilComponents/Status.js";
import TextInputDokument from "./EditProfilComponents/TextInputDokument.js";
export default {
components: {
Kontakt,
EditKontakt,
Adresse,
EditAdresse,
Status,
TextInputDokument,
},
inject: ["profilUpdateTopic"],
props: {
list: Object,
//? Prop used to determine how many options the select should initially show
size: {
type: Number,
default: null,
},
//? Content for the aria label of the select
ariaLabel: {
type: String,
required: true,
},
profilUpdate: String,
topic: String,
breadcrumb: String,
},
emits: {
//? update:modelValue event is needed to notify the v-model when the value has changed
["update:profilUpdate"]: null,
["update:topic"]: null,
["update:breadcrumb"]: null,
submit: null,
select: null,
},
data() {
return {
view: null,
data: null,
breadcrumbItems: [],
modal_topic: this.topic,
properties: null,
};
},
methods: {
addItem: function () {
this.view =
this.modal_topic == this.profilUpdateTopic["Private Kontakte"]
? "EditKontakt"
: "EditAdresse";
//? updates the topic when a Kontakt or an Address should be added
this.modal_topic =
this.modal_topic == this.profilUpdateTopic["Private Kontakte"]
? this.profilUpdateTopic["Add Kontakt"]
: this.profilUpdateTopic["Add Adresse"];
this.$emit("update:topic", this.modal_topic);
this.breadcrumbItems.push(this.modal_topic);
this.$emit("update:breadcrumb", this.breadcrumbItems);
this.data =
this.view == "EditAdresse"
? {
//? add flag
add: true,
adresse_id: null,
strasse: null,
typ: null,
plz: null,
ort: null,
zustelladresse: false,
}
: {
//? add flag
add: true,
kontakt_id: null,
kontakttyp: null,
kontakt: null,
anmerkung: null,
zustellung: false,
};
},
deleteItem: function (item) {
//? delete flag
item.data.delete = true;
this.$emit("update:profilUpdate", item.data);
//? updates the topic when a Kontakt or an Address should be deleted
this.modal_topic = this.modal_topic == this.profilUpdateTopic["Private Adressen"]
? this.profilUpdateTopic["Delete Adresse"]
: this.profilUpdateTopic["Delete Kontakt"];
this.$emit("update:topic", this.modal_topic);
this.$emit("submit");
},
profilUpdateEmit: function (event) {
//? passes the updated profil information to the parent component
this.$emit("update:profilUpdate", event);
},
updateOptions: function (event, item) {
this.properties = item;
this.data = item.data;
this.view = item.view;
if (item.title) {
//? emits the selected topic to the parent component
this.modal_topic = item.topic;
this.$emit("update:topic", this.modal_topic);
//? emits the new item for the breadcrumb in the parent component
this.breadcrumbItems.push(item.title);
} else {
if (item.data.kontakttyp) {
this.breadcrumbItems.push(item.data.kontakttyp);
this.breadcrumbItems.push(item.data.kontakt);
} else if (item.data.strasse) {
this.breadcrumbItems.push(item.data.strasse);
}
}
this.$emit("update:breadcrumb", this.breadcrumbItems);
},
},
computed: {},
created() {
//? JSON parse and stringify are used to deep clone the objects
this.properties = {...this.list};
this.data = this.list.data
? JSON.parse(JSON.stringify(this.list.data))
: null;
this.view = this.list.view
? JSON.parse(JSON.stringify(this.list.view))
: null;
},
mounted() {
},
template: /*html*/ `
<template v-if="!view">
<div class="list-group">
<template v-for="item in data">
<div class="d-flex flex-row align-items-center">
<button style="position:relative" type="button" class=" list-group-item list-group-item-action" @click="updateOptions($event,item)" >
<!-- render title of options -->
<p v-if="item.title" class="my-1" >{{item.title}}</p>
<!-- else render list view of items -->
<div v-else class="my-2 me-4" >
<component :is="item.listview" v-bind="item"></component>
</div>
</button>
<button v-if="item.listview" @click="deleteItem(item)" type="button" class="mx-3 btn btn-danger btn-circle" :aria-label="$p.t('profilUpdate','deleteItem')" :title="$p.t('profilUpdate','deleteItem')" ><i class="fa fa-trash" aria-hide="true"></i></button>
</div>
</template>
</div>
<div v-if="Array.isArray(data)" class="mt-4 d-flex justify-content-center">
<button @click="addItem" type="button" class="btn btn-primary btn-circle" :aria-label="$p.t('profilUpdate','addItem')" :title="$p.t('profilUpdate','addItem')"><i class="fa fa-plus" aria-hide="true"></i></button>
</div>
</template>
<template v-else>
<component @profilUpdate="profilUpdateEmit" :is="view" v-bind="properties" :data="data" ></component>
</template>
`,
};
@@ -0,0 +1,438 @@
import {CoreFilterCmpt} from "../../../components/filter/Filter.js";
import Mailverteiler from "./ProfilComponents/Mailverteiler.js";
import AusweisStatus from "./ProfilComponents/FhAusweisStatus.js";
import QuickLinks from "./ProfilComponents/QuickLinks.js";
import Adresse from "./ProfilComponents/Adresse.js";
import Kontakt from "./ProfilComponents/Kontakt.js";
import ProfilEmails from "./ProfilComponents/ProfilEmails.js";
import RoleInformation from "./ProfilComponents/RoleInformation.js";
import ProfilInformation from "./ProfilComponents/ProfilInformation.js";
import FetchProfilUpdates from "./ProfilComponents/FetchProfilUpdates.js";
import EditProfil from "./ProfilModal/EditProfil.js";
import ApiProfilUpdate from '../../../api/factory/profilUpdate.js';
export default {
components: {
CoreFilterCmpt,
Mailverteiler,
AusweisStatus,
QuickLinks,
Adresse,
Kontakt,
ProfilEmails,
RoleInformation,
ProfilInformation,
FetchProfilUpdates,
EditProfil,
},
inject: ["sortProfilUpdates", "collapseFunction", "language","isEditable"],
data() {
return {
showModal: false,
collapseIconBetriebsmittel: true,
editDataFilter: null,
preloadedPhrasen:{},
// tabulator options
zutrittsgruppen_table_options: {
persistenceID: "filterTableStudentProfilZutrittsgruppen",
persistence: {
columns: false
},
height: 200,
layout: "fitColumns",
columns: [{
title: Vue.computed(() => this.preloadedPhrasen.zutrittsGruppenPhrase),
field: "bezeichnung"
}],
},
betriebsmittel_table_options: {
persistenceID: "filterTableStudentProfilBetriebsmittel",
persistence: {
columns: false
},
height: 300,
layout: "fitColumns",
responsiveLayout: "collapse",
responsiveLayoutCollapseUseFormatters: false,
responsiveLayoutCollapseFormatter: Vue.$collapseFormatter,
columns: [
{
title:
"<i id='collapseIconBetriebsmittel' role='button' class='fa-solid fa-angle-down '></i>",
field: "collapse",
headerSort: false,
headerFilter: false,
formatter: "responsiveCollapse",
maxWidth: 40,
headerClick: this.collapseFunction,
},
{
title: Vue.computed(()=>this.preloadedPhrasen.entlehnteBetriebsmittelPhrase),
field: "betriebsmittel",
headerFilter: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() =>this.preloadedPhrasen.inventarnummerPhrase) ,
field: "Nummer",
headerFilter: true,
resizable: true,
minWidth: 200,
visible: true
},
{
title: Vue.computed(() =>this.preloadedPhrasen.ausgabedatum) ,
field: "Ausgegeben_am",
headerFilter: true,
minWidth: 200,
visible: true
},
],
},
};
},
props: {
data: Object,
editData: Object,
},
provide() {
return {
studiengang_kz: Vue.computed({ get: () => this.data.studiengang_kz }),
}
},
methods: {
betriebsmittelTableBuilt: function () {
this.$refs.betriebsmittelTable.tabulator.setColumns(this.betriebsmittel_table_options.columns)
this.$refs.betriebsmittelTable.tabulator.setData(this.data.mittel);
},
zutrittsgruppenTableBuilt: function () {
this.$refs.zutrittsgruppenTable.tabulator.setColumns(this.zutrittsgruppen_table_options.columns)
this.$refs.zutrittsgruppenTable.tabulator.setData(
this.data.zuttritsgruppen
);
},
fetchProfilUpdates: function () {
this.$api
.call(ApiProfilUpdate.selectProfilRequest())
.then((res) => {
if (!res.error && res) {
this.data.profilUpdates = res.data?.length
? res.data.sort(this.sortProfilUpdates)
: null;
}
});
},
hideEditProfilModal: function () {
//? checks the editModal component property result, if the user made a successful request or not
if (this.$refs.editModal.result) {
this.$api
.call(ApiProfilUpdate.selectProfilRequest())
.then((request) => {
if (!request.error && request.data) {
this.data.profilUpdates = request.data;
this.data.profilUpdates.sort(this.sortProfilUpdates);
} else {
console.error("Error when fetching profile updates: " + request);
}
})
.catch((err) => {
console.error(err);
});
} else {
// when modal was closed without submitting request
}
this.showModal = false;
this.editDataFilter = null;
},
showEditProfilModal(view) {
if (view) {
this.editDataFilter = view;
}
this.showModal = true;
// after a state change, wait for the DOM updates to complete
Vue.nextTick(() => {
this.$refs.editModal.show();
});
},
},
computed: {
fotoStatus() {
return this.data?.fotoStatus ?? null;
},
filteredEditData() {
return this.editDataFilter
? this.editData.data[this.editDataFilter]
: this.editData;
},
profilInformation() {
if (!this.data) {
return {};
}
return {
Vorname: this.data.vorname,
Nachname: this.data.nachname,
Username: this.data.username,
Anrede: this.data.anrede,
Titel: this.data.titel,
Postnomen: this.data.postnomen,
foto_sperre: this.data.foto_sperre,
foto: this.data.foto,
};
},
roleInformation() {
if (!this.data) {
return {};
}
return {
geburtsdatum: {
label: `${this.$p.t('profil','Geburtsdatum')}`,
value: this.data.gebdatum
},
geburtsort: {
label: `${this.$p.t('profil','Geburtsort')}`,
value: this.data.gebort
},
personenkennzeichen: {
label: `${this.$p.t('person','personenkennzeichen')}`,
value: this.data.personenkennzeichen
},
studiengang: {
label: `${this.$p.t('lehre','studiengang')}`,
value: this.data.studiengang
},
semester: {
label: `${this.$p.t('lehre','semester')}`,
value: this.data.semester
},
verband: {
label: `${this.$p.t('lehre','lehrverband')}`,
value: this.data.verband
},
gruppe: {
label: `${this.$p.t('lehre','gruppe')}`,
value: this.data.gruppe.trim()
}
};
},
},
created() {
// preload phrasen
this.$p.loadCategory('profil').then(() => {
this.preloadedPhrasen.zutrittsGruppenPhrase = this.$p.t('profil/zutrittsGruppen');
this.preloadedPhrasen.entlehnteBetriebsmittelPhrase = this.$p.t('profil/entlehnteBetriebsmittel');
this.preloadedPhrasen.inventarnummerPhrase = this.$p.t('profil/inventarnummer');
this.preloadedPhrasen.ausgabedatum = this.$p.t('profil/ausgabedatum');
this.preloadedPhrasen.loaded = true;
});
//? sorts the profil Updates: pending -> accepted -> rejected
this.data.profilUpdates?.sort(this.sortProfilUpdates);
},
watch: {
'language.value'(newVal) {
if(this.$refs.betriebsmittelTable) this.$refs.betriebsmittelTable.tabulator.setColumns(this.betriebsmittel_table_options.columns)
if(this.$refs.zutrittsgruppenTable) this.$refs.zutrittsgruppenTable.tabulator.setColumns(this.zutrittsgruppen_table_options.columns)
}
},
template: /*html*/ `
<div class="container-fluid text-break fhc-form">
<edit-profil v-if="showModal" ref="editModal" @hideBsModal="hideEditProfilModal"
:value="JSON.parse(JSON.stringify(filteredEditData))" :titel="$p.t('profil','profilBearbeiten')"></edit-profil>
<!-- ROW -->
<div class="row">
<!-- HIDDEN QUICK LINKS -->
<div class="d-md-none col-12 ">
<!--TODO: uncomment when implemented
<div class="row py-2">
<div class="col">
<quick-links :title="$p.t('profil','quickLinks')" :mobile="true"></quick-links>
</div>
</div>-->
<!-- Bearbeiten Button -->
<div v-if="isEditable" class="row ">
<div class="col mb-3">
<button @click="showEditProfilModal" type="button" class="card text-start w-100 btn btn-outline-secondary" >
<div class="row">
<div class="col-2">
<i class="fa fa-edit"></i>
</div>
<div class="col-10">{{$p.t('ui','bearbeiten')}}</div>
</div>
</button>
</div>
</div>
<div v-if="data.profilUpdates" class="row mb-3">
<div class="col">
<!-- MOBILE PROFIL UPDATES -->
<fetch-profil-updates v-if="data.profilUpdates && data.profilUpdates.length" @fetchUpdates="fetchProfilUpdates" :data="data.profilUpdates"></fetch-profil-updates>
</div>
</div>
</div>
<!-- END OF HIDDEN QUCK LINKS -->
<!-- MAIN PANNEL -->
<div class="col-sm-12 col-md-8 col-xxl-9 ">
<!-- ROW WITH PROFIL IMAGE AND INFORMATION -->
<!-- INFORMATION CONTENT START -->
<!-- ROW WITH THE PROFIL INFORMATION -->
<div class="row mb-4 ">
<div class="col-lg-12 col-xl-6 ">
<div class="row mb-4">
<div class="col">
<!-- PROFIL INFORMATION -->
<profil-information @showEditProfilModal="showEditProfilModal" :title="$p.t('profil','studentIn')" :data="profilInformation" :fotoStatus="fotoStatus"></profil-information>
</div>
</div>
<div class="row mb-4">
<div class=" col-lg-12">
<!-- STUDENT INFO -->
<role-information :title="$p.t('profil','studentInformation')" :data="roleInformation"></role-information>
</div>
</div>
<!-- START OF SECOND PROFIL INFORMATION COLUMN -->
</div>
<div class="col-xl-6 col-lg-12 ">
<div class="row mb-4">
<div class="col">
<!-- EMAILS -->
<profil-emails :title="this.$p.t('person','email')" :data="data.emails" ></profil-emails>
</div>
</div>
<div class="row mb-4 ">
<div class="col">
<!-- PRIVATE KONTAKTE-->
<div class="card">
<div class="card-header">
<div class="row">
<div @click="showEditProfilModal('Private_Kontakte')" class="col-auto" type="button">
<i class="fa fa-edit"></i>
</div>
<div class="col">
<span>{{$p.t('profil','privateKontakte')}}</span>
</div>
</div>
</div>
<div class="card-body ">
<div class="gy-3 row">
<div v-for="element in data.kontakte" class="col-12">
<Kontakt :data="element"></Kontakt>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row mb-4">
<div class="col">
<!-- PRIVATE ADRESSEN-->
<div class="card">
<div class="card-header">
<div class="row">
<div @click="showEditProfilModal('Private_Adressen')" class="col-auto" type="button">
<i class="fa fa-edit"></i>
</div>
<div class="col">
<span>{{$p.t('profil','privateAdressen')}}</span>
</div>
</div>
</div>
<div class="card-body">
<div class="gy-3 row ">
<div v-for="element in data.adressen" class="col-12">
<Adresse :data="element"></Adresse>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div >
<!-- SECOND ROW UNDER THE PROFIL IMAGE AND INFORMATION WITH THE TABLES -->
<div class="row">
<div class="col-12 mb-4" >
<core-filter-cmpt
v-if="preloadedPhrasen.loaded"
@tableBuilt="betriebsmittelTableBuilt"
:title="$p.t('profil','entlehnteBetriebsmittel')"
ref="betriebsmittelTable"
:tabulator-options="betriebsmittel_table_options"
tableOnly
:sideMenu="false" />
</div>
<div class="col-12 mb-4" >
<core-filter-cmpt
v-if="preloadedPhrasen.loaded"
@tableBuilt="zutrittsgruppenTableBuilt"
:title="$p.t('profil','zutrittsGruppen')"
ref="zutrittsgruppenTable"
:tabulator-options="zutrittsgruppen_table_options"
tableOnly
:sideMenu="false"
noColumnFilter />
</div>
</div>
<!-- END OF MAIN CONTENT COL -->
</div>
<!-- START OF SIDE PANEL -->
<div class="col-md-4 col-xxl-3 col-sm-12 text-break" >
<!--TODO: uncomment when implemented
<div class="row d-none d-md-block mb-3">
<div class="col">
<quick-links :title="$p.t('profil','quickLinks')"></quick-links>
</div>
</div>-->
<!-- Bearbeiten Button -->
<div class="row d-none d-md-block">
<div class="col mb-3">
<button @click="()=>showEditProfilModal()" type="button" class="card text-start w-100 btn btn-outline-secondary" >
<div class="row">
<div class="col-2">
<i class="fa fa-edit"></i>
</div>
<div class="col-10">{{$p.t('ui','bearbeiten')}}</div>
</div>
</button>
</div>
</div>
<div v-if="data.profilUpdates" class="row d-none d-md-block mb-3">
<div class="col mb-3">
<!-- PROFIL UPDATES -->
<fetch-profil-updates v-if="data.profilUpdates && data.profilUpdates.length" @fetchUpdates="fetchProfilUpdates" :data="data.profilUpdates"></fetch-profil-updates>
</div>
</div>
<div class="row mb-3" >
<div class="col-12">
<ausweis-status :data="data.zutrittsdatum"></ausweis-status>
</div>
</div>
<!-- START OF THE SECOND ROW IN THE SIDE PANEL -->
<div class="row">
<div class="col">
<!-- HIER SIND DIE MAILVERTEILER -->
<mailverteiler :title="$p.t('profil','mailverteiler')" :data="data?.mailverteiler"></mailverteiler>
</div>
<!-- END OF THE SECOND ROW IN THE SIDE PANEL -->
</div>
<!-- END OF SIDE PANEL -->
</div>
<!-- END OF CONTAINER ROW-->
</div>
<!-- END OF CONTAINER -->
</div>
`,
};
@@ -0,0 +1,174 @@
import QuickLinks from "./ProfilComponents/QuickLinks.js";
import Mailverteiler from "./ProfilComponents/Mailverteiler.js";
import ProfilEmails from "./ProfilComponents/ProfilEmails.js";
import RoleInformation from "./ProfilComponents/RoleInformation.js";
import ProfilInformation from "./ProfilComponents/ProfilInformation.js";
export default {
data() {
return {};
},
components: {
QuickLinks,
Mailverteiler,
ProfilEmails,
RoleInformation,
ProfilInformation,
},
props: ["data"],
provide() {
return {
studiengang_kz: Vue.computed({ get: () => this.data.studiengang_kz }),
}
},
methods: {},
computed: {
fotoStatus() {
return this.data?.fotoStatus ?? null;
},
profilInformation() {
if (!this.data) {
return {};
}
return {
Vorname: this.data.vorname,
Nachname: this.data.nachname,
Username: this.data.username,
Anrede: this.data.anrede,
Titel: this.data.titel,
Postnomen: this.data.postnomen,
foto_sperre: this.data.foto_sperre,
foto: this.data.foto,
};
},
personEmails() {
return this.data?.emails ? this.data.emails : [];
},
roleInformation() {
if (!this.data) {
return {};
}
return {
geburtsdatum: {
label: `${this.$p.t('profil','Geburtsdatum')}`,
value: this.data.gebdatum
},
geburtsort: {
label: `${this.$p.t('profil','Geburtsort')}`,
value: this.data.gebort
},
personenkennzeichen: {
label: `${this.$p.t('person','personenkennzeichen')}`,
value: this.data.personenkennzeichen
},
studiengang: {
label: `${this.$p.t('lehre','studiengang')}`,
value: this.data.studiengang
},
semester: {
label: `${this.$p.t('lehre','semester')}`,
value: this.data.semester
},
verband: {
label: `${this.$p.t('lehre','lehrverband')}`,
value: this.data.verband
},
gruppe: {
label: `${this.$p.t('lehre','gruppe')}`,
value: this.data.gruppe.trim()
}
};
},
},
mounted() {
},
template: /*html*/ `
<div class="container-fluid text-break fhc-form" >
<!-- ROW -->
<div class="row">
<!-- HIDDEN QUICK LINKS -->
<!-- uncomment when implemented
<div class="d-md-none col-12 ">
<quick-links :title="$p.t('profil','quickLinks')" :mobile="true"></quick-links>
</div>-->
<!-- END OF HIDDEN QUCK LINKS -->
<!-- MAIN PANNEL -->
<div class="col-sm-12 col-md-8 col-xxl-9 ">
<!-- ROW WITH PROFIL IMAGE AND INFORMATION -->
<!-- INFORMATION CONTENT START -->
<!-- ROW WITH THE PROFIL INFORMATION -->
<div class="row mb-4">
<!-- FIRST KAESTCHEN -->
<div class="col-lg-12 col-xl-6 ">
<div class="row mb-4">
<div class="col">
<profil-information :data="profilInformation" :title="$p.t('profil','studentIn')" :fotoStatus="fotoStatus"></profil-information>
</div>
</div>
<!-- START OF SECOND PROFIL INFORMATION COLUMN -->
<!-- END OF PROFIL INFORMATION ROW -->
<!-- INFORMATION CONTENT END -->
</div>
<div class="col-xl-6 col-lg-12 ">
<div class="row mb-4">
<div class="col">
<!-- EMAILS -->
<profil-emails :title="this.$p.t('person','email')" :data="personEmails"></profil-emails>
</div>
</div>
<!-- SECOND ROW OF SECOND COLUMN IN MAIN CONTENT -->
<div class=" row mb-4">
<div class=" col-lg-12">
<role-information :title="$p.t('profil','studentInformation')" :data="roleInformation"></role-information>
</div>
</div>
<!-- END OF SECOND ROW OF SECOND COLUMN IN MAIN CONTENT -->
<!-- END OF THE SECOND INFORMATION COLUMN -->
</div>
<!-- START OF THE SECOND PROFIL INFORMATION ROW -->
<!-- ROW WITH PROFIL IMAGE AND INFORMATION END -->
</div >
<!-- END OF MAIN CONTENT COL -->
</div>
<!-- START OF SIDE PANEL -->
<div class="col-md-4 col-xxl-3 col-sm-12 text-break" >
<!-- SRART OF QUICK LINKS IN THE SIDE PANEL -->
<!-- START OF THE FIRDT ROW IN THE SIDE PANEL -->
<!-- THESE QUCK LINKS ARE ONLY VISIBLE UNTIL VIEWPORT MD -->
<!--TODO: uncomment when implemented
<div class="row d-none d-md-block mb-3">
<div class="col">
<quick-links :title="$p.t('profil','quickLinks')"></quick-links>
</div>
</div>-->
<!-- START OF THE SECOND ROW IN THE SIDE PANEL -->
<div class="row">
<div class="col">
<!-- HIER SIND DIE MAILVERTEILER -->
<mailverteiler :title="$p.t('profil','mailverteiler')" :data="data?.mailverteiler"></mailverteiler>
</div>
<!-- END OF THE SECOND ROW IN THE SIDE PANEL -->
</div>
<!-- END OF SIDE PANEL -->
</div>
<!-- END OF CONTAINER ROW-->
</div>
<!-- END OF CONTAINER -->
</div>
`,
};
@@ -0,0 +1,226 @@
import BsModal from "../../Bootstrap/Modal.js";
import Alert from "../../Bootstrap/Alert.js";
import Kontakt from "../Profil/ProfilComponents/Kontakt.js";
import Adresse from "../Profil/ProfilComponents/Adresse.js";
import ApiProfilUpdate from '../../../api/factory/profilUpdate.js';
export default {
components: {
BsModal,
Kontakt,
Adresse,
},
inject: ["profilUpdateStates"],
mixins: [BsModal],
props: {
title: {
type: String,
},
value: {
type: Object,
},
setLoading: {
type: Function,
},
/*
* NOTE(chris):
* Hack to expose in "emits" declared events to $props which we use
* in the v-bind directive to forward all events.
* @see: https://github.com/vuejs/core/issues/3432
*/
onHideBsModal: Function,
onHiddenBsModal: Function,
onHidePreventedBsModal: Function,
onShowBsModal: Function,
onShownBsModal: Function,
},
data() {
return {
data: this.value,
loading: false,
result: false,
info: null,
files: null,
};
},
methods: {
getProfilStatus: async function () {
return (
FHC_JS_DATA_STORAGE_OBJECT.app_root +
FHC_JS_DATA_STORAGE_OBJECT.ci_router +
`/Cis/ProfilUpdate/show/${dms_id}`
);
},
getDocumentLink: function (dms_id) {
return (
FHC_JS_DATA_STORAGE_OBJECT.app_root +
FHC_JS_DATA_STORAGE_OBJECT.ci_router +
`/Cis/ProfilUpdate/show/${dms_id}`
);
},
handleRequest: function (type) {
this.loading = true;
this.setLoading(true);
this.$api
.call(ApiProfilUpdate[
type.toLowerCase() == "accept"
? "acceptProfilRequest"
: "denyProfilRequest"
](this.data))
.then((res) => {
this.result = true;
})
.catch((e) => this.$fhcAlert.handleSystemError)
.finally(() => {
this.setLoading(false);
this.loading = false;
this.hide();
});
},
},
computed: {
getComponentView: function () {
if (this.data.topic.toLowerCase().includes("kontakt")) {
return "kontakt";
} else if (this.data.topic.toLowerCase().includes("adresse")) {
return "adresse";
} else {
return "text_input";
}
},
},
created() {
// only fetching the profilUpdate Attachemnts if the profilUpdate actually has attachments
if (this.value.attachment_id) {
this.$api
.call(ApiProfilUpdate.getProfilRequestFiles(
this.data.profil_update_id
))
.then((res) => {
this.files = res.data;
});
}
},
mounted() {
this.modal = this.$refs.modalContainer.modal;
},
popup(options) {
return BsModal.popup.bind(this)(null, options);
},
template: /*html*/ `
<bs-modal v-show="!loading" ref="modalContainer" v-bind="$props" body-class="" dialog-class="modal-lg" class="bootstrap-alert" :backdrop="false" >
<template v-slot:title>
{{title}}
</template>
<template v-slot:default>
<div class="row">
<div class="form-underline mb-2 col-12 col-sm-6">
<div class="form-underline-titel">{{$p.t('global','status')}}: </div>
<span class="form-underline-content" >{{data.status}}</span>
</div>
<div v-if="data.status!==profilUpdateStates['Pending']" class="form-underline mb-2 col-12 col-sm-6">
<div class="form-underline-titel">{{$p.t('profilUpdate','statusDate')}}: </div>
<!-- only status timestamp and status message can be null in the database -->
<span class="form-underline-content" >{{data.status_timestamp?data.status_timestamp:'-'}}</span>
</div>
<div class="form-underline mb-2 col-12 col-sm-6">
<div class="form-underline-titel">{{$p.t('profilUpdate','userID')}}: </div>
<span class="form-underline-content" >{{data.uid}}</span>
</div>
<div class="form-underline mb-2 col-12 col-sm-6">
<div class="form-underline-titel">{{$p.t('global','name')}}: </div>
<span class="form-underline-content" >{{data.name}}</span>
</div>
<div class="form-underline mb-2 col-12 col-sm-6">
<div class="form-underline-titel">{{$p.t('profilUpdate','anfrageThema')}}: </div>
<span class="form-underline-content" >{{data.topic}}</span>
</div>
<div class="form-underline mb-2 col-12 col-sm-6">
<div class="form-underline-titel">{{$p.t('profilUpdate','anfrageDatum')}}:</div>
<span class="form-underline-content" >{{data.insertamum}}</span>
</div>
</div>
<!-- Row with the status message is only visible if the request is not pending and the message is not empty -->
<div v-if="data.status !== profilUpdateStates['Pending'] && data.status_message" class="row">
<div class="col">
<div class="form-underline mb-2 ">
<div class="form-underline-titel">{{$p.t('profilUpdate','statusMessage')}}</div>
<textarea class="form-control" rows="4" disabled>{{data.status_message}} </textarea>
</div>
</div>
</div>
<div class="row my-4">
<div class="col ">
<div class="card">
<div class="card-header">{{$p.t('profilUpdate','update')}}</div>
<div class="card-body">
<template v-if="getComponentView==='text_input'">
<div class="form-underline mb-2">
<div class="form-underline-titel">{{data.topic}}</div>
<span class="form-underline-content" >{{data.requested_change.value}}</span>
</div>
</template>
<component v-else :is="getComponentView" :withZustelladresse="getComponentView==='adresse'?true:false" :data="data.requested_change"></component>
</div>
</div>
<div v-if="files?.length" class="card mt-3">
<div class="card-header">{{$p.t('profilUpdate','nachweisdokumente')}}</div>
<div class="card-body">
<a v-for="file in files" target="_blank" :href="getDocumentLink(file.dms_id)" >{{file.name}}</a>
</div>
</div>
</div>
</div>
</template>
<template v-if="data.status === profilUpdateStates['Pending']" v-slot:footer>
<div class="form-underline flex-fill">
<div class="form-underline-titel">{{$p.t('global','nachricht')}}</div>
<div class="d-flex flex-row gap-2">
<input class="form-control " v-model="data.status_message" />
<button @click="handleRequest('accept')" class="text-nowrap btn btn-success">{{$p.t('profilUpdate','accept')}} <i class="fa fa-check"></i></button>
<button @click="handleRequest('deny')" class="text-nowrap btn btn-danger">{{$p.t('profilUpdate','deny')}} <i class="fa fa-xmark"></i></button>
</div>
</div>
</template>
</bs-modal>`,
};
@@ -0,0 +1,416 @@
import { CoreFilterCmpt } from "../../filter/Filter.js";
import AcceptDenyUpdate from "./AcceptDenyUpdate.js";
import Alert from "../../../components/Bootstrap/Alert.js";
import Loading from "../../../components/Loader.js";
import ApiProfilUpdate from '../../../api/factory/profilUpdate.js';
const sortProfilUpdates = (ele1, ele2, thisPointer) => {
let result = 0;
if (ele1.status === thisPointer.profilUpdateStates["Pending"]) {
result = -1;
} else if (ele1.status === thisPointer.profilUpdateStates["Accepted"]) {
result =
ele2.status === thisPointer.profilUpdateStates["Rejected"] ? -1 : 1;
} else {
result = 1;
}
if (ele1.status === ele2.status) {
//? if they have the same status , insert_date gets compared for order
result =
new Date(ele2.insertamum.split(".").reverse().join("-")) -
new Date(ele1.insertamum.split(".").reverse().join("-"));
}
return result;
};
export default {
components: {
CoreFilterCmpt,
Loading,
AcceptDenyUpdate,
},
inject: ["profilUpdateStates"],
props: {
id: {
type: String,
},
},
data() {
return {
categoryLoaded: false,
showModal: false,
modalData: null,
loading: false,
filter: "Pending",
profil_update_id: Number(this.id),
};
},
computed: {
profilUpdateEvents: function () {
return [
{
"event": "dataProcessed",
"handler": this.handleDataProcessed
}
];
},
profilUpdateOptions: function () {
return {
ajaxURL: 'dummy',
ajaxRequestFunc: (url, config, params) => {
return this.$api.call(ApiProfilUpdate.getProfilUpdateWithPermission(params.filter));
},
ajaxParams: () => {
let filter = '';
switch (this.filter) {
case this.profilUpdateStates["Pending"]:
filter = this.profilUpdateStates["Pending"];
break;
case this.profilUpdateStates["Accepted"]:
filter = this.profilUpdateStates["Accepted"];
break;
case this.profilUpdateStates["Rejected"]:
filter = this.profilUpdateStates["Rejected"];
break;
default:
filter = '';
}
return {
"filter": filter
};
},
ajaxResponse: (url, params, response) => {
//url - the URL of the request
//params - the parameters passed with the request
//response - the JSON object returned in the body of the response.
//? sorts the response data from the backend
if (response?.data)
response.data.sort((ele1, ele2) => sortProfilUpdates(ele1, ele2, this));
return response.data;
},
//? adds tooltip with the status message of a profil update request if its status is not pending
columnDefaults: {
tooltip: (e, cell, onRendered) => {
//e - mouseover event
//cell - cell component
//onRendered - onRendered callback registration function
let statusMessage = cell.getData().status_message;
let statusDate = cell.getData().status_timestamp;
let status = cell.getData().status;
if (!statusMessage) {
return null;
}
let el = document.createElement("div");
el.classList.add("border", "border-dark");
let statusDateEl = document.createElement("span");
statusDateEl.classList.add("d-block", "mb-1");
statusDateEl.innerHTML =
"Request was " + status + " on " + statusDate;
let statusMessageEl = document.createElement("span");
statusMessageEl.innerHTML = "Status message: " + statusMessage;
el.appendChild(statusDateEl);
el.appendChild(statusMessageEl);
return el;
},
},
rowContextMenu: (e, component) => {
let menu = [];
if (
component.getData().status === this.profilUpdateStates["Pending"]
) {
menu.push(
{
label: `<i class='fa fa-check'></i> ${this.$p.t(
"profilUpdate",
"acceptUpdate"
)}`,
action: (e, column) => {
this.$api
.call(ApiProfilUpdate.acceptProfilRequest(column.getData()))
.then((res) => {
this.$refs.UpdatesTable.tabulator.setData();
})
.catch((e) => this.$fhcAlert.handleSystemError);
},
},
{
separator: true,
},
{
label: ` <i style='width:16px' class='text-center fa fa-xmark'></i> ${this.$p.t(
"profilUpdate",
"denyUpdate"
)}`,
action: (e, column) => {
this.$api
.call(ApiProfilUpdate.denyProfilRequest(column.getData()))
.then((res) => {
this.$refs.UpdatesTable.tabulator.setData();
})
.catch((e) => this.$fhcAlert.handleSystemError);
},
},
{
separator: true,
},
{
label: `<i class='fa fa-eye'></i> ${this.$p.t(
"profilUpdate",
"showRequest"
)}`,
action: (e, column) => {
this.showAcceptDenyModal(column.getData());
},
}
);
} else {
menu.push({
label: `<i class='fa fa-eye'></i> ${this.$p.t(
"profilUpdate",
"showRequest"
)}`,
action: (e, column) => {
this.showAcceptDenyModal(column.getData());
},
});
}
return menu;
},
height: 600,
layout: "fitColumns",
columns: [
{
title: this.$p.t("profilUpdate", "UID"),
field: "uid",
minWidth: 200,
resizable: true,
headerFilter: true,
//responsive:0,
},
{
title: this.$p.t("profilUpdate", "Name"),
field: "name",
minWidth: 200,
resizable: true,
headerFilter: true,
//responsive:0,
},
{
title: this.$p.t("profilUpdate", "Topic"),
field: "topic",
resizable: true,
minWidth: 200,
headerFilter: true,
//responsive:0,
},
{
title: this.$p.t("profilUpdate", "insertamum"),
field: "insertamum",
resizable: true,
headerFilter: true,
minWidth: 200,
//responsive:0,
},
{
title: this.$p.t("profilUpdate", "Status"),
field: "status_translated",
hozAlign: "center",
headerFilter: true,
formatter: (cell, para) => {
let iconClasses = "";
let status = cell.getRow().getData().status;
switch (status) {
case this.profilUpdateStates["Pending"]:
iconClasses += "fa fa-lg fa-circle-info text-info ";
break;
case this.profilUpdateStates["Accepted"]:
iconClasses += "fa fa-lg fa-circle-check text-success ";
break;
case this.profilUpdateStates["Rejected"]:
iconClasses += "fa fa-lg fa-circle-xmark text-danger ";
break;
}
return `<div class='row justify-content-center'><div class='col-2'><i class='${iconClasses}'></i></div> <div class='col-4'><span>${cell.getValue()}</span></div></div>`;
},
resizable: true,
minWidth: 200,
//responsive:0,
},
{
title: this.$p.t("profilUpdate", "actions"),
headerSort: false,
formatter: (cell, params) => {
let STATUS_PENDING =
cell.getRow().getData().status ==
this.profilUpdateStates["Pending"];
let html = `<div class="d-flex justify-content-evenly align-items-center">
<button class="btn border-primary border-2" id="showButton"><i class="fa-solid fa-eye fhc-primary-color"></i></button>
${
STATUS_PENDING ?
`<button class="btn border-success border-2" id="acceptButton"><i class='fa fa-lg fa-circle-check text-success'></i></button>
<button class="btn border-danger border-2" id="denyButton"><i class=' fa fa-lg fa-circle-xmark text-danger'></i></button>`
:
``
}
</div>`;
// Convert the HTML string to an HTML node
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const node = doc.body.firstChild;
// Add event listeners
node
.querySelector("#showButton")
.addEventListener("click", () => {
this.showAcceptDenyModal(cell.getRow().getData());
});
if (STATUS_PENDING) {
node
.querySelector("#acceptButton")
.addEventListener("click", () => {
this.acceptProfilUpdate(cell.getRow().getData());
});
node
.querySelector("#denyButton")
.addEventListener("click", () => {
this.denyProfilUpdate(cell.getRow().getData());
});
}
return node;
},
minWidth: 200,
resizable: true,
hozAlign: "center",
},
],
};
}
},
methods: {
denyProfilUpdate: function (data) {
this.$api
.call(ApiProfilUpdate.denyProfilRequest(data))
.then((res) => {
// block when the request was successful
})
.catch((e) => this.$fhcAlert.handleSystemError)
.finally(() => {
this.$refs.UpdatesTable.tabulator.setData();
});
},
acceptProfilUpdate: function (data) {
this.$api
.call(ApiProfilUpdate.acceptProfilRequest(data))
.then((res) => {
// block when the request was successful
})
.catch((e) => this.$fhcAlert.handleSystemError)
.finally(() => {
// update the data inside the table
this.$refs.UpdatesTable.tabulator.setData();
});
},
setLoading: function (newValue) {
this.loading = newValue;
},
hideAcceptDenyModal: function () {
//? checks the AcceptDenyModal component property result, if the user made a successful request or not
if (this.$refs.AcceptDenyModal.result) {
//? refetches the data, if any request was denied or accepted
//* setData will call the ajaxURL again to refresh the data
this.$refs.UpdatesTable.tabulator.setData();
} else {
// when modal was closed without submitting request
}
this.showModal = false;
this.modalData = null;
},
showAcceptDenyModal(value) {
this.modalData = value;
if (!this.modalData) {
return;
}
this.showModal = true;
// after a state change, wait for the DOM updates to complete
Vue.nextTick(() => {
this.$refs.AcceptDenyModal.show();
});
},
updateData: function (event) {
this.$refs.UpdatesTable.tabulator.setData();
//? store the selected view in the session storage of the browser
sessionStorage.setItem("filter", event.target.value);
},
handleDataProcessed: function () {
if (this.profil_update_id) {
const arrayRowData = this.$refs.UpdatesTable.tabulator
.getData()
.filter((row) => {
return row.profil_update_id === this.profil_update_id;
});
if (arrayRowData.length) {
this.showAcceptDenyModal(arrayRowData[0]);
}
}
}
},
watch: {
loading: function (newValue, oldValue) {
if (newValue) {
this.$refs.loadingModalRef.show();
} else {
this.$refs.loadingModalRef.hide();
}
},
},
created() {
this.$p.loadCategory("profilUpdate").then(() => {
this.categoryLoaded = true;
});
},
mounted() {
//? opens the AcceptDenyUpdate Modal if a preselected profil_update_id was passed to the component (used for email links)
if (sessionStorage.getItem("filter")) {
this.filter = sessionStorage.getItem("filter");
}
},
template: /*html*/ `
<div>
<accept-deny-update :title="$p.t('profilUpdate','profilUpdateRequest')" v-if="showModal" ref="AcceptDenyModal" @hideBsModal="hideAcceptDenyModal" :value="JSON.parse(JSON.stringify(modalData))" :setLoading="setLoading" ></accept-deny-update>
<div class="form-underline flex-fill ">
<div class="form-underline-titel">{{$p.t('ui','anzeigen')}} </div>
<select class="mb-4 form-select" v-model="filter" @change="updateData" aria-label="Profil updates display selection">
<option :selected="true" :value="profilUpdateStates['Pending']" >{{$p.t('profilUpdate','pendingRequests')}}</option>
<option :value="profilUpdateStates['Accepted']">{{$p.t('profilUpdate','acceptedRequests')}}</option>
<option :value="profilUpdateStates['Rejected']">{{$p.t('profilUpdate','rejectedRequests')}}</option>
<option :value="'Alle'">{{$p.t('profilUpdate','allRequests')}}</option>
</select>
</div>
<loading ref="loadingModalRef" :timeout="0"></loading>
<core-filter-cmpt v-if="profilUpdateStates && categoryLoaded" :title="$p.t('profilUpdate','profilUpdateRequests')" ref="UpdatesTable" :tabulatorEvents="profilUpdateEvents" :tabulator-options="profilUpdateOptions" tableOnly :sideMenu="false" />
</div>`,
};
@@ -0,0 +1,271 @@
import {CoreFilterCmpt} from "../../../components/filter/Filter.js";
import VueDatePicker from '../../vueDatepicker.js.php';
import ApiOrt from '../../../api/factory/ort.js'
export const Raumsuche = {
name: "Raumsuche",
props: {
},
components: {
VueDatePicker,
CoreFilterCmpt,
InputNumber: primevue.inputnumber,
},
data() {
return {
phrasenPromise: null,
phrasenResolved: false,
tabulatorUuid: Vue.ref(0),
tableBuiltResolve: null,
tableBuiltPromise: null,
roomtypes: null,
defaultType: {
raumtyp_kurzbz: '',
beschreibung: Vue.computed(() => this.$p.t('global/alle'))
},
anzahl: 1,
selectedType: null,
datum: new Date(),
von: Vue.ref({
hours: new Date().getHours(),
minutes: new Date().getMinutes()
}),
bis: Vue.ref({
hours: new Date().getHours() + 1,
minutes: new Date().getMinutes()
}),
datepickerTextInputOptions: {
enterSubmit: true,
tabSubmit: true,
selectOnFocus: true,
format: 'dd.MM.yyyy',
escClose: true
},
timepickerTextInputOptions: {
enterSubmit: true,
tabSubmit: true,
selectOnFocus: true,
format: 'HH:mm',
escClose: true
},
raumsucheTableOptions: {
height: Vue.ref(400),
index: 'ort_kurzbz',
layout: 'fitColumns',
placeholder: this.$p.t('global/noDataAvailable'),
columns: [
{title: Vue.computed(() => this.$p.t('rauminfo/raum_kurzbz')), field: 'ort_kurzbz', widthGrow: 1},
{title: Vue.computed(() => this.$p.t('global/bezeichnung')), field: 'bezeichnung', widthGrow: 2},
{title: Vue.computed(() => this.$p.t('rauminfo/raumnummer')), field: 'nummer', widthGrow: 1},
{title: Vue.computed(() => this.$p.t('rauminfo/personcap')), field: 'personen', widthGrow: 1},
{title: Vue.computed(() => this.$p.t('rauminfo/rauminfo')),
field: 'linkInfo', formatter: this.linkFormatter, widthGrow: 1},
{title: Vue.computed(() => this.$p.t('rauminfo/roomReservations')),
field: 'linkRes', formatter: this.linkFormatter, widthGrow: 1}
],
persistence: false,
},
raumsucheTableEventHandlers: [{
event: "tableBuilt",
handler: async () => {
this.tableBuiltResolve()
}
}
]};
},
methods: {
tableResolve(resolve) {
this.tableBuiltResolve = resolve
},
linkFormatter(cell) {
const val = cell.getValue();
const field = cell.getField();
const arialabel = (field === 'linkInfo')
? this.$p.t('rauminfo/rauminfo')
: this.$p.t('rauminfo/roomReservations');
if(val) {
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'<a href="'+val+'" aria-label="' + arialabel + '">' +
'<i class="fa fa-arrow-up-right-from-square me-1 fhc-primary-color" ></i>' +
'</a></div>'
} else {
return '<div style="display: flex; justify-content: center; align-items: center; height: 100%">' +
'-</div>'
}
},
roomPlanLink(room) {
return FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/CisVue/Cms/getRoomInformation/' + room.ort_kurzbz
},
roomInfoLink(room) {
return FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router
+ '/CisVue/Cms/content/' + room.content_id
},
getTimeString(time) {
const hours = String(time.hours).padStart(2, '0');
const minutes = String(time.minutes).padStart(2, '0');
return `${hours}:${minutes}`
},
setupData(data){
const d = data.map(room => {
return {
ort_kurzbz: room.ort_kurzbz,
bezeichnung: room.bezeichnung.replace('&amp;', '&'),
nummer: room.planbezeichnung,
personen: room.max_person,
linkInfo: room.content_id ? this.roomInfoLink(room) : null,
linkRes: this.roomPlanLink(room)
}
})
this.$refs.raumsucheTable.tabulator.setData(d);
},
loadRoomTypes() {
this.$api.call(ApiOrt.getRoomTypes())
.then(res => {
res?.data?.forEach(type => {
type.beschreibung = type.beschreibung.replace('&amp;', '&')
})
this.selectedType = this.defaultType
this.roomtypes = res?.data ?? []
})
},
loadRooms() {
this.$api.call(ApiOrt.getRooms(this.datum.toISOString(), this.getTimeString(this.von), this.getTimeString(this.bis), this.selectedType?.raumtyp_kurzbz ?? '', this.anzahl))
.then(res => {
if(res?.data?.retval) this.setupData(res.data.retval)
})
},
handleUuidDefined(uuid) {
this.tabulatorUuid = uuid
},
search(){
this.loadRooms()
},
setRoute(val) {
// TODO: router push
},
dateFormat(date) {
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
return `${day}.${month}.${year}`
},
timeFormat(date) {
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${hours}:${minutes}`;
},
async setupMounted() {
this.tableBuiltPromise = new Promise(this.tableResolve)
await this.tableBuiltPromise
this.loadRoomTypes()
this.loadRooms()
const tableID = this.tabulatorUuid ? ('-' + this.tabulatorUuid) : ''
const tableDataSet = document.getElementById('filterTableDataset' + tableID);
if(!tableDataSet) return
const rect = tableDataSet.getBoundingClientRect();
const h = window.visualViewport.height - rect.top - 100
if(this.$refs.raumsucheTable) {
this.$refs.raumsucheTable.$refs.table.style.setProperty('height', h+'px')
}
}
},
computed: {
isDarkMode(){
return this.$theme.theme_name.value == 'dark';
}
},
created() {
this.phrasenPromise = this.$p.loadCategory(['rauminfo', 'global'])
this.phrasenPromise.then(()=> {this.phrasenResolved = true})
},
mounted() {
this.setupMounted()
},
template: `
<h1 class="h3">{{$p.t('rauminfo/roomSearch')}}</h1>
<hr>
<div class="row">
<div class="col-12 col-lg-2">
<VueDatePicker
:dark="isDarkMode"
v-model="datum"
:clearable="false"
date-picker
:enable-time-picker="false"
:format="dateFormat"
:text-input="datepickerTextInputOptions"
:min-date="new Date()"
auto-apply>
</VueDatePicker>
</div>
<div class="col-12 col-lg-1">
<VueDatePicker
:dark="isDarkMode"
v-model="von"
:clearable="false"
time-picker
:format="timeFormat"
:text-input="timepickerTextInputOptions"
:is-24="true"
auto-apply
>
</VueDatePicker>
</div>
<div class="col-12 col-lg-1">
<VueDatePicker
:dark="isDarkMode"
v-model="bis"
:clearable="false"
time-picker
:format="timeFormat"
:text-input="timepickerTextInputOptions"
:is-24="true"
auto-apply>
</VueDatePicker>
</div>
<div class="col-12 col-lg-3">
<select ref="raumtyp" id="raumtypSelect" v-model="selectedType" class="form-select"
:aria-label="$p.t('global/studiensemester_auswaehlen')" @change="setRoute($event.target.value)">
<option :key="defaultType" selected :value="defaultType">{{defaultType.beschreibung}}</option>
<option v-for="typ in roomtypes" :key="typ" :value="typ">{{typ.beschreibung}}</option>
</select>
</div>
<div class="col-12 col-lg-3">
<InputNumber v-model="anzahl"
:prefix="$p.t('rauminfo/minCapacity') + ': '"
inputId="anzahlInput" :min="1" :max="1000"
:style="{'width': '100%'}"
/>
</div>
<div class="col-12 col-lg-2">
<button class="btn btn-primary border-0" @click="search">{{ $p.t('rauminfo/roomSearch') }} <i class="fa fa-magnifying-glass"></i></button>
</div>
</div>
<core-filter-cmpt
v-if="phrasenResolved"
@uuidDefined="handleUuidDefined"
:title="''"
ref="raumsucheTable"
:tabulator-options="raumsucheTableOptions"
:tabulator-events="raumsucheTableEventHandlers"
tableOnly
:sideMenu="false"
/>
`,
};
export default Raumsuche;
@@ -0,0 +1,14 @@
export default {
props: {
event: {
type: Object,
required: true,
},
},
template: `
<div class="cis-renderer-feiertage-calendar-event">
<i class="event-icon fa-regular fa-calendar"></i>
<span class="event-title fw-bold text-center">{{ event.titel }}</span>
</div>`,
};
@@ -0,0 +1,21 @@
import { formatDate } from "../../../../helpers/DateHelpers.js"
export default {
props:{
event: {
type: Object,
required: true,
},
},
methods:{
methodFormatDate: function (d) {
return formatDate(d);
},
},
template: `
<div>
<h2>{{event.titel ? event.titel : event.topic}}</h2>
<p><i id="ferienEventIcon" class="fa-regular fa-calendar me-2"></i>{{methodFormatDate(event?.datum)}}</p>
</div>`,
}
@@ -0,0 +1,11 @@
export default {
props:{
event: {
type: Object,
required: true,
}
},
template:`
<div>{{ $p.t('global','feiertag') }}</div>
`
}
@@ -0,0 +1,85 @@
export default {
props:{
event: {
type: Object,
required: true
}
},
computed:{
tooltipString() {
const tooltipArray = [];
tooltipArray.push([
this.$p.t('global/uhrzeit'),
[this.start, this.end].join(' - ')
].join(": "));
tooltipArray.push([
this.$p.t('profilUpdate/topic'),
this.event.topic
].join(": "));
tooltipArray.push([
this.$p.t('person/ort'),
this.event.ort_kurzbz
].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(": "));
} else {console.log(this.event.lektor);
tooltipArray.push([
this.$p.t('lehre/lektor'),
this.event.lektor.map(lektor => lektor.kurzbz).join("\n")
].join(": "));
}
}
return tooltipArray.join("\n");
},
start() {
return luxon.Duration
.fromISOTime(this.event.beginn)
.toISOTime({ suppressSeconds: true });
},
end() {
return luxon.Duration
.fromISOTime(this.event.ende)
.toISOTime({ suppressSeconds: true });
}
},
template: /*html*/`
<div
class="cis-renderer-lehreinheit-calendar-event calendar-event-default h-100 w-100 p-1"
@wheel.stop
>
<div
v-if="!event.allDayEvent && event?.beginn && event?.ende"
class="event-time d-none d-xl-grid h-100"
>
<span>{{ start }}</span>
<span>{{ end }}</span>
</div>
<div class="event-text" v-tooltip="tooltipString">
<span class="event-topic">{{ event.topic }}</span>
<span class="event-place">{{ event.ort_kurzbz }}</span>
<span
v-for="(lektor,index) in event.lektor.slice(0, 3)"
class="event-lectors"
>
{{ lektor.kurzbz }}
</span>
<span
v-if="event.lektor.length > 3"
class="event-lectors-plus"
>
... +{{ event.lektor.length - 3 }}
</span>
</div>
</div>
`,
}
@@ -0,0 +1,148 @@
import { numberPadding, formatDate } from "../../../../helpers/DateHelpers.js"
import LvMenu from "../../Mylv/LvMenu.js";
import ApiLvPlan from '../../../../api/factory/lvPlan.js';
import ApiAddons from '../../../../api/factory/addons.js';
export default {
components:{
LvMenu,
},
props:{
event: {
type: Object,
required: true,
}
},
data() {
return {
lvMenu: []
};
},
computed: {
lektorenLinks: function () {
if (!this.event || !Array.isArray(this.event.lektor) || !this.event.lektor.length) return "a";
let lektorenLinks = {};
this.event.lektor.forEach((lektor) => {
lektorenLinks[lektor.kurzbz] = FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + `/Cis/Profil/View/${lektor.mitarbeiter_uid}`;
})
return lektorenLinks;
},
getOrtContentLink: function () {
if (!this.event || !this.event.ort_content_id) return "a";
return FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + `/CisVue/Cms/content/${this.event.ort_content_id}`
},
start_time: function () {
if (!this.event.beginn)
return 'N/A';
if (!(this.event.beginn instanceof Date)) {
return this.event.beginn;
}
return numberPadding(this.event.beginn.getHours()) + ":" + numberPadding(this.event.beginn.getMinutes());
},
end_time: function () {
if (!this.event.ende)
return 'N/A';
if (!(this.event.ende instanceof Date)) {
return this.event.ende;
}
return numberPadding(this.event.ende.getHours()) + ":" + numberPadding(this.event.ende.getMinutes());
}
},
methods: {
mehtodNumberPadding: function (number) {
return numberPadding(number);
},
methodFormatDate: function (d) {
return formatDate(d);
},
},
created() {
if (this.event.type == 'lehreinheit') {
this.$api
.call(ApiLvPlan.getLehreinheitStudiensemester(Array.isArray(this.event.lehreinheit_id) ? this.event.lehreinheit_id[0] : this.event.lehreinheit_id))
.then(res => res.data)
.then(studiensemester_kurzbz => this.$api.call(
ApiAddons.getLvMenu(
this.event.lehrveranstaltung_id,
studiensemester_kurzbz
)
))
.then(res => {
this.lvMenu = res.data;
});
}
},
template: `
<div>
<h5>
{{$p.t('lvinfo','lehrveranstaltungsinformationen')}}
</h5>
<table class="table table-hover mb-4">
<tbody>
<tr>
<th>{{
$p.t('global','datum')?
$p.t('global','datum')+':'
:''
}}</th>
<td>{{methodFormatDate(event.datum)}}</td>
</tr>
<tr>
<th>{{
$p.t('ui','zeitraum')?
$p.t('ui','zeitraum')+':'
:''
}}</th>
<td>{{start_time + ' - ' + end_time}}</td>
</tr>
<tr>
<th>{{
$p.t('global','raum')?
$p.t('global','raum')+':'
:''
}}</th>
<td>
<a v-if="event.ort_content_id" :aria-label="$p.t('global','raum')" :title="$p.t('global','raum')" :href="getOrtContentLink"><i class="fa fa-arrow-up-right-from-square me-1" aria-hidden="true" style="color:#00649C"></i></a>
{{event.ort_kurzbz}}
</td>
</tr>
<tr>
<th>{{
$p.t('lehre','lehrveranstaltung')?
$p.t('lehre','lehrveranstaltung')+':'
:''
}}</th>
<td>{{'('+event.lehrform+') ' + event.lehrfach_bez}}</td>
</tr>
<tr>
<th>{{
$p.t('lehre','lektor')?
$p.t('lehre','lektor')+':'
:''
}}</th>
<td>
<div id="lektorenContainer">
<div v-for="lektor in event.lektor" class="d-block">
<a v-if="lektorenLinks[lektor.kurzbz]" :aria-label="$p.t('lehre','lektor')" :title="$p.t('lehre','lektor')" :href="lektorenLinks[lektor.kurzbz]"><i class="fa fa-arrow-up-right-from-square me-1" style="color:#00649C" aria-hidden="true"></i></a>
{{lektor.kurzbz}}
</div>
</div>
</td>
</tr>
<tr>
<th>{{
$p.t('lehre','organisationseinheit')?
$p.t('lehre','organisationseinheit')+':'
:''
}}</th>
<td>{{event.organisationseinheit}}</td>
</tr>
</tbody>
</table>
<lv-menu :containerStyles="['p-0']" :rowStyles="['m-0']" v-if="lvMenu.length" :menu="lvMenu" />
</div>`,
}
@@ -0,0 +1,12 @@
export default {
props:{
event: {
type: Object,
required: true,
}
},
template:`
<div v-if="event.titel">{{ event.titel + ' - ' + event.lehrfach_bez + ' [' + event.ort_kurzbz+']'}}</div>
<div v-else>{{ event.lehrfach_bez + ' [' + event.ort_kurzbz+']'}}</div>
`
}
@@ -0,0 +1,84 @@
export default {
props: {
event: {
type: Object,
required: true
}
},
computed: {
tooltipString() {
const tooltipArray = [];
tooltipArray.push([
this.$p.t('global/uhrzeit'),
[this.start, this.end].join(' - ')
].join(": "));
tooltipArray.push([
this.$p.t('profilUpdate/topic'),
this.event.topic
].join(": "));
tooltipArray.push([
this.$p.t('person/ort'),
this.event.ort_kurzbz
].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(": "));
} else {
tooltipArray.push([
this.$p.t('lehre/lektor'),
this.event.lektor.map(lektor => lektor.kurzbz).join("\n")
].join(": "));
}
}
return tooltipArray.join("\n");
},
start() {
return luxon.Duration
.fromISOTime(this.event.beginn)
.toISOTime({ suppressSeconds: true });
},
end() {
return luxon.Duration
.fromISOTime(this.event.ende)
.toISOTime({ suppressSeconds: true });
}
},
template: /* html */`
<div
class="cis-renderer-reservierungen-calendar-event calendar-event-default h-100 w-100 p-1"
>
<div
v-if="!event.allDayEvent && event?.beginn && event?.ende"
class="event-time d-grid h-100"
>
<span>{{ start }}</span>
<span>{{ end }}</span>
</div>
<div class="event-text" v-tooltip="tooltipString">
<span class="event-topic">{{ event.topic }}</span>
<span
v-for="lektor in event.lektor.slice(0, 3)"
class="event-lectors"
>
{{ lektor.kurzbz }}
</span>
<span
v-if="event.lektor.length > 3"
class="event-lectors-plus"
>
... +{{ event.lektor.length - 3 }}
</span>
<span class="event-place">{{ event.ort_kurzbz }}</span>
</div>
</div>
`,
}
@@ -0,0 +1,110 @@
import { numberPadding, formatDate } from "../../../../helpers/DateHelpers.js"
import LvMenu from "../../Mylv/LvMenu.js";
export default {
props:{
event: {
type: Object,
required: true,
},
lvMenu:{
type: Object,
required: false,
default: null,
},
},
components:{
LvMenu,
},
computed: {
lektorenLinks: function () {
if (!this.event || !Array.isArray(this.event.lektor) || !this.event.lektor.length) return "a";
let lektorenLinks = {};
this.event.lektor.forEach((lektor) => {
lektorenLinks[lektor.kurzbz] = FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + `/Cis/Profil/View/${lektor.mitarbeiter_uid}`;
})
return lektorenLinks;
},
getOrtContentLink: function () {
if (!this.event || !this.event.ort_content_id) return "a";
return FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + `/CisVue/Cms/content/${this.event.ort_content_id}`
},
start_time: function () {
if (!this.event.beginn)
return 'N/A';
if (!(this.event.beginn instanceof Date)) {
return this.event.beginn;
}
return numberPadding(this.event.beginn.getHours()) + ":" + numberPadding(this.event.beginn.getMinutes());
},
end_time: function () {
if (!this.event.ende)
return 'N/A';
if (!(this.event.ende instanceof Date)) {
return this.event.ende;
}
return numberPadding(this.event.ende.getHours()) + ":" + numberPadding(this.event.ende.getMinutes());
}
},
methods: {
mehtodNumberPadding: function (number) {
return numberPadding(number);
},
methodFormatDate: function (d) {
return formatDate(d);
},
},
template: `
<div>
<h3>
{{$p.t('lvinfo','lehrveranstaltungsinformationen')}}
</h3>
<table class="table table-hover mb-4">
<tbody>
<tr>
<th>{{
$p.t('global','datum')?
$p.t('global','datum')+':'
:''
}}</th>
<td>{{methodFormatDate(event.datum)}}</td>
</tr>
<tr>
<th>{{
$p.t('ui','zeitraum')?
$p.t('ui','zeitraum')+':'
:''
}}</th>
<td>{{start_time + ' - ' + end_time}}</td>
</tr>
<tr>
<th>{{
$p.t('global','raum')?
$p.t('global','raum')+':'
:''
}}</th>
<td>
<a v-if="event.ort_content_id" :aria-label="$p.t('global','raum')" :title="$p.t('global','raum')" :href="getOrtContentLink"><i class="fa fa-arrow-up-right-from-square me-1" style="color:#00649C" aria-hidden="true"></i></a>
{{event.ort_kurzbz}}
</td>
</tr>
<tr>
<th>{{
$p.t('lehre','lektor')?
$p.t('lehre','lektor')+':'
:''
}}</th>
<td>
<div v-for="lektor in event.lektor" class="d-block">
<a v-if="lektorenLinks[lektor.kurzbz]" :aria-label="$p.t('lehre','lektor')" :tooltip="$p.t('lehre','lektor')" :href="lektorenLinks[lektor.kurzbz]"><i class="fa fa-arrow-up-right-from-square me-1" aria-hidden="true" style="color:#00649C"></i></a>
{{lektor.kurzbz}}
</div>
</td>
</tr>
</tbody>
</table>
</div>`,
}
@@ -0,0 +1,11 @@
export default {
props:{
event: {
type: Object,
required: true,
}
},
template:`
<div >{{ event.topic + ' [' + event.ort_kurzbz+']'}}</div>
`
}
+30
View File
@@ -0,0 +1,30 @@
export default {
data(){
return {
allActiveLanguages: FHC_JS_DATA_STORAGE_OBJECT.server_languages,
}
},
emits: ['languageChanged'],
methods:{
changeLanguage: function(lang){
if(this.allActiveLanguages.some(l => l.sprache === lang))
{
const isReload = document.querySelector('[cis4Reload]')
this.$p.setLanguage(lang)
.then(res => res.data)
.then(data =>
{
if(isReload) window.location.reload()
else this.$emit('languageChanged', lang);
})
}
},
},
template:/*html*/`
<div class="container">
<div class="row justify-content-center align-items-center flex-nowrap overflow-hidden">
<button v-for="lang in allActiveLanguages" @click.prevent="changeLanguage(lang.sprache)" class="col fhc-text-light sprachen-entry btn text-center w-100" :selected="$p.user_language.value==lang.sprache">{{lang.bezeichnung}}</button>
</div>
</div>
`,
};
+367
View File
@@ -0,0 +1,367 @@
import LvUebersicht from "../Mylv/LvUebersicht.js";
export default {
data(){
return {
studienSemester :[],
selectedStudiensemester: null,
studiengaenge:[],
selectedStudiengang:null,
studienOrdnung: [],
selectedStudienordnung: null,
semester:[],
selectedSemester:null,
lehrveranstaltungen: [],
selectedLehrveranstaltung: null,
menu:null,
}
},
provide(){
return {
studium_studiengang : Vue.computed(()=> this.selectedStudiengang),
studium_studiensemester: Vue.computed(() => this.selectedStudiensemester),
studium_semester: Vue.computed(() => this.selectedSemester),
studium_studienordnung: Vue.computed(() => this.selectedStudienordnung),
}
},
components: {
LvUebersicht,
},
watch:{
selectedStudiensemester: function(newVal, oldVal){
if(newVal && newVal != oldVal){
const studiensemester =this.getDataFromLocalStorage("sudiensemester");
if (newVal && (!studiensemester || (studiensemester && studiensemester != newVal))){
this.storeDataToLocalStorage("sudiensemester", newVal);
}
}
},
selectedSemester: function (newVal, oldVal) {
if (newVal && newVal != oldVal) {
const semester = this.getDataFromLocalStorage("semester");
if (!semester || (semester && semester != newVal)) {
this.storeDataToLocalStorage("semester", newVal);
}
}
},
selectedStudiengang: function (newVal, oldVal) {
if (newVal && newVal != oldVal) {
const studiengang = this.getDataFromLocalStorage("studiengang");
if (!studiengang || (studiengang && studiengang != newVal)) {
this.storeDataToLocalStorage("studiengang", JSON.stringify(newVal));
}
}
},
selectedStudienordnung: function (newVal, oldVal) {
if (newVal && newVal != oldVal) {
const studienordnung = this.getDataFromLocalStorage("studienordnung");
if (!studienordnung || (studienordnung && studienordnung != newVal)) {
this.storeDataToLocalStorage("studienordnung", JSON.stringify(newVal));
}
}
},
},
methods:{
changeStudiensemester(value){
let studiensemester = this.$refs.studiensemester;
studiensemester.selectedIndex = (studiensemester.selectedIndex + value + studiensemester.options.length) % studiensemester.options.length;
this.changeSelectedStudienSemester(studiensemester.value);
},
changeStudiengang(value) {
let studiengang = this.$refs.studiengaenge;
studiengang.selectedIndex = (studiengang.selectedIndex + value + studiengang.options.length) % studiengang.options.length;
this.changeSelectedStudienGang(studiengang.value);
},
changeSemester(value) {
let semester = this.$refs.semester;
semester.selectedIndex = (semester.selectedIndex + value + semester.options.length) % semester.options.length;
this.changeSelectedSemester(semester.value);
},
changeStudienordnung(value) {
let studienordnung = this.$refs.studienordnung;
let newSelectIndex = (studienordnung.selectedIndex + value + studienordnung.options.length) % studienordnung.options.length;
if(studienordnung.options[newSelectIndex].disabled){
newSelectIndex = (newSelectIndex + value + studienordnung.options.length) % studienordnung.options.length;
}
studienordnung.selectedIndex = newSelectIndex;
this.changeSelectedStudienPlan(studienordnung.value);
},
storeDataToLocalStorage(key,value){
localStorage.setItem(key, value);
},
getDataFromLocalStorage(key){
const value = localStorage.getItem(key);
return value;
},
changeSelectedStudienSemester(studiensemester_kurzbz) {
this.$fhcApi.factory.studium.getAllStudienSemester(studiensemester_kurzbz, this.selectedStudiengang, this.selectedSemester, this.selectedStudienordnung)
.then(data => data.data)
.then(res => {
this.extractPropertyValues(res);
})
},
changeSelectedStudienGang(studiengang_kz) {
this.$fhcApi.factory.studium.getAllStudienSemester(this.selectedStudiensemester, studiengang_kz, this.selectedSemester, this.selectedStudienordnung)
.then(data => data.data)
.then(res => {
this.extractPropertyValues(res);
})
},
changeSelectedSemester(semester) {
this.$fhcApi.factory.studium.getAllStudienSemester(this.selectedStudiensemester, this.selectedStudiengang, semester, this.selectedStudienordnung)
.then(data => data.data)
.then(res => {
this.extractPropertyValues(res);
})
},
changeSelectedStudienPlan(studienplan_id) {
this.$fhcApi.factory.studium.getAllStudienSemester(this.selectedStudiensemester, this.selectedStudiengang, this.selectedSemester, studienplan_id)
.then(data => data.data)
.then(res => {
this.extractPropertyValues(res);
})
},
openLvUebersicht(lehrveranstaltung) {
this.selectedLehrveranstaltung = lehrveranstaltung;
//convert lehrveranstaltung properties for compatibility with LvPlan LvModal
this.selectedLehrveranstaltung.type ="lehreinheit";
this.selectedLehrveranstaltung.lehreinheit_id = this.selectedLehrveranstaltung.lehrveranstaltung_id;
if(this.selectedLehrveranstaltung){
Vue.nextTick(()=>{
this.$refs.lvUebersicht.show();
})
}
},
sortStudienSemester(studienSemester){
let regex = new RegExp(/^(WS|SS)([0-9]{4})/);
studienSemester.sort((sem1,sem2)=>{
let [sem1Match, sem1Semester, sem1Year] = sem1.studiensemester_kurzbz.match(regex);
let [sem2Match, sem2Semester, sem2Year] = sem2.studiensemester_kurzbz.match(regex);
if(sem1Year == sem2Year){
return sem1Semester > sem2Semester? -1:1;
}
return sem1Year > sem2Year? -1:1;
})
},
setHash(val) {
// TODO: make this a router param to enable history
location.hash = val;
},
extractPropertyValues(res){
let { studienSemester, studiengang, semester, studienplan, lehrveranstaltungen } = res;
this.sortStudienSemester(studienSemester.all);
this.studienSemester = studienSemester.all;
this.selectedStudiensemester = studienSemester.preselected.studiensemester_kurzbz;
this.studiengaenge = studiengang.all;
this.selectedStudiengang = studiengang.preselected?.studiengang_kz;
this.semester = semester.all;
this.selectedSemester = semester?.preselected;
this.studienOrdnung = studienplan.all;
this.selectedStudienordnung = studienplan.preselected?.studienplan_id;
this.lehrveranstaltungen = lehrveranstaltungen;
this.lehrveranstaltungen.sort((lv1, lv2) => {
if (lv1.bezeichnung.toLowerCase() > lv2.bezeichnung.toLowerCase()) {
return 1;
} else if (lv1.bezeichnung.toLowerCase() < lv2.bezeichnung.toLowerCase()) {
return -1;
} else {
return 0;
}
});
this.lehrveranstaltungen.forEach((lehrveranstaltung)=>{
lehrveranstaltung.lehrveranstaltungen.sort((lv1,lv2)=>{
if (lv1.bezeichnung.toLowerCase() > lv2.bezeichnung.toLowerCase()) {
return 1;
} else if (lv1.bezeichnung.toLowerCase() < lv2.bezeichnung.toLowerCase()) {
return -1;
} else {
return 0;
}
})
})
},
studienordnungTitel(studienordnung){
if(!studienordnung) return "";
return `${studienordnung?.bezeichnung}-${studienordnung?.orgform_kurzbz} ( ${studienordnung?.orgform_bezeichnung}, ${studienordnung?.sprache} )`;
},
studiengangTitel(studiengang) {
if (!studiengang) return "";
return `${studiengang?.kurzbzlang} (${studiengang?.bezeichnung})`;
},
studiensemesterTitel(studiensemester){
if (!studiensemester) return "";
let studiensemester_regex = new RegExp(/^(WS|SS)([0-9]{4})/);
let match = studiensemester.match(studiensemester_regex);
switch(match[1]){
case "WS":
return `Wintersemester ${match[2]}`;
case "SS":
return `Sommersemester ${match[2]}`;
default:
return `${studiensemester}`;
}
}
},
computed:{
selectedLehrveranstaltungTitel(){
const studiengang = this.studiengaenge.find((studiengang) => studiengang.studiengang_kz == this.selectedStudiengang);
return `${this.selectedLehrveranstaltung?.bezeichnung} ${this.selectedLehrveranstaltung?.lehrform_kurzbz} / ${studiengang.kurzbzlang}-${this.selectedSemester} ${this.selectedLehrveranstaltung?.orgform_kurzbz} (${this.selectedStudiensemester})`;
},
computedStudienOrdnung(){
if(!this.studienOrdnung) return null;
return Object.values(this.studienOrdnung).reduce((carry, item)=>{
if(!carry[item.bezeichnung]){
carry[item.bezeichnung] = [];
}
carry[item.bezeichnung].push(item);
return carry;
},{});
},
computedStudienOrdnungSelectValues() {
if (!this.computedStudienOrdnung) return null;
let result = [];
Object.entries(this.computedStudienOrdnung).forEach(([key,value])=>{
result.push({
bezeichnung: `Studienordnung: ${key}`,
disabled: true,
});
value.forEach((studienplan)=>{
result.push({
studienplan:studienplan,
diabled: false,
bezeichnung: `${studienplan?.bezeichnung}-${studienplan?.orgform_kurzbz} ( ${studienplan?.orgform_bezeichnung}, ${studienplan?.sprache} )`
});
})
});
return result;
},
},
created(){
const studiensemester = this.getDataFromLocalStorage("sudiensemester") ?? undefined;
const studiengang = JSON.parse(this.getDataFromLocalStorage("studiengang")) ?? undefined;
const semester = this.getDataFromLocalStorage("semester") ?? undefined;
const studienordnung = JSON.parse(this.getDataFromLocalStorage("studienordnung")) ?? undefined;
// only fetch default data if no data is stored in the local storage
this.$fhcApi.factory.studium.getAllStudienSemester(studiensemester, studiengang, semester, studienordnung)
.then(data => data.data)
.then(res => {
this.extractPropertyValues(res);
})
},
template: `
<div>
<h2>Studium</h2>
<hr>
<lv-uebersicht ref="lvUebersicht" :titel="selectedLehrveranstaltungTitel" :event="selectedLehrveranstaltung" :studiensemester="selectedStudiensemester" v-if="selectedLehrveranstaltung">
<template #content>
<div v-if="Array.isArray(selectedLehrveranstaltung.lektoren) && selectedLehrveranstaltung.lektoren.length>0" class="mb-4">
<h4>Lektoren:</h4>
<a :href="'mailto:'+lektor?.email" class="fhc-link-color mx-2" v-for="lektor in selectedLehrveranstaltung.lektoren">{{lektor.name}}</a>
</div>
<h4>Menu:</h4>
</template>
</lv-uebersicht>
<div class="lvOptions">
<div>
<h6>Studiensemester:</h6>
<div class="input-group">
<button class="btn btn-outline-secondary" type="button" :disabled="false" @click="changeStudiensemester(1)" :aria-label="$p.t('global','previous')" :title="$p.t('global','previous')">
<i class="fa fa-caret-left" aria-hidden="true"></i>
</button>
<select ref="studiensemester" v-model="selectedStudiensemester" class="form-select" :aria-label="$p.t('global/studiensemester_auswaehlen')" @change="setHash($event.target.value)">
<option v-for="semester in studienSemester" @click="changeSelectedStudienSemester(semester.studiensemester_kurzbz)" :key="semester" :value="semester.studiensemester_kurzbz">{{studiensemesterTitel(semester.studiensemester_kurzbz) }}</option>
</select>
<button class="btn btn-outline-secondary" type="button" :disabled="false" @click="changeStudiensemester(-1)" :aria-label="$p.t('global','next')" :title="$p.t('global','next')">
<i class="fa fa-caret-right" aria-hidden="true"></i>
</button>
</div>
</div>
<div>
<h6>Studiengang:</h6>
<div class="input-group">
<button class="btn btn-outline-secondary" type="button" :disabled="false" @click="changeStudiengang(-1)" :aria-label="$p.t('global','previous')" :title="$p.t('global','previous')">
<i class="fa fa-caret-left" aria-hidden="true"></i>
</button>
<select ref="studiengaenge" v-model="selectedStudiengang" class="form-select" :aria-label="$p.t('global/studiensemester_auswaehlen')" @change="setHash($event.target.value)">
<option v-for="studiengang in studiengaenge" @click="changeSelectedStudienGang(studiengang.studiengang_kz)" :key="studiengang.studiengang_kz" :value="studiengang.studiengang_kz" >{{studiengangTitel(studiengang)}}</option>
</select>
<button class="btn btn-outline-secondary" type="button" :disabled="false" @click="changeStudiengang(1)" :aria-label="$p.t('global','next')" :title="$p.t('global','next')">
<i class="fa fa-caret-right" aria-hidden="true"></i>
</button>
</div>
</div>
<div>
<h6>Semester:</h6>
<div class="input-group">
<button class="btn btn-outline-secondary" type="button" :disabled="false" @click="changeSemester(-1)" :aria-label="$p.t('global','previous')" :title="$p.t('global','previous')">
<i class="fa fa-caret-left" aria-hidden="true"></i>
</button>
<select ref="semester" v-model="selectedSemester" class="form-select" :aria-label="$p.t('global/studiensemester_auswaehlen')" @change="setHash($event.target.value)">
<option v-for="sem in semester" @click="changeSelectedSemester(sem)" :key="semester" :value="sem">{{sem}}. Semester</option>
</select>
<button class="btn btn-outline-secondary" type="button" :disabled="false" @click="changeSemester(1)" :aria-label="$p.t('global','next')" :title="$p.t('global','next')">
<i class="fa fa-caret-right" aria-hidden="true"></i>
</button>
</div>
</div>
<div>
<h6>Studienordnung:</h6>
<div class="input-group">
<button class="btn btn-outline-secondary" type="button" :disabled="false" @click="changeStudienordnung(-1)" :aria-label="$p.t('global','previous')" :title="$p.t('global','previous')">
<i class="fa fa-caret-left" aria-hidden="true"></i>
</button>
<select ref="studienordnung" v-model="selectedStudienordnung" class="form-select" :aria-label="$p.t('global/studiensemester_auswaehlen')" @change="setHash($event.target.value)">
<option v-for="ordnung in computedStudienOrdnungSelectValues" :disabled="ordnung.disabled" @click="changeSelectedStudienPlan(ordnung?.studienplan?.studienplan_id)" :key="ordnung?.studienplan?.bezeichnung " :value="ordnung?.studienplan?.studienplan_id">{{ordnung.bezeichnung}}</option>
</select>
<button class="btn btn-outline-secondary" type="button" :disabled="false" @click="changeStudienordnung(1)" :aria-label="$p.t('global','next')" :title="$p.t('global','next')">
<i class="fa fa-caret-right" aria-hidden="true"></i>
</button>
</div>
</div>
</div>
<hr>
<div class="lvUebersicht " >
<template v-for="lehrveranstaltung in lehrveranstaltungen" :key="lehrveranstaltung.lehrveranstaltung_id">
<div class="card" v-if="Array.isArray(lehrveranstaltung.lehrveranstaltungen) && lehrveranstaltung.lehrveranstaltungen.length >0" >
<div class="card-header">
<h5 class=" card-title">{{lehrveranstaltung.bezeichnung}}</h5>
<h6 class=" card-subtitle">{{lehrveranstaltung.lehrform_kurzbz}}</h6>
</div>
<div class="card-body">
<ul class="list-group list-group-flush">
<li class="d-flex list-group-item" v-for="lv in lehrveranstaltung.lehrveranstaltungen">
<a class="fhc-link-color d-block me-auto" href="#" @click="openLvUebersicht(lv)">{{lv.bezeichnung}}</a>
<p>{{lv.lehrform_kurzbz}}</p>
</li>
</ul>
</div>
</div>
</template>
</div>
</div>
`
};
+33
View File
@@ -0,0 +1,33 @@
export default {
data:()=>{
return{
theme: null,
}
},
methods:{
switchTheme(nextTheme){
this.theme = nextTheme;
this.$theme.switchTheme(this.theme);
},
},
computed:{
nextTheme(){
return this.$theme.theme_modes[(this.$theme.theme_modes.indexOf(this.theme) + 1) % this.$theme.theme_modes.length];
},
},
created(){
this.theme = localStorage.getItem('theme');
if (!this.theme || !this.$theme.theme_modes.includes(this.theme)) {
this.theme = this.$theme.theme_modes[0];
}
},
template:/*html*/`
<button id="themeSwitch" :aria-label="$p.t('global','switchTheme',[nextTheme])" @click="switchTheme(nextTheme)" class="fhc-primary-highlight-bg align-self-center btn btn-secondary rounded-5">
<i v-if="theme == 'light'" class="fa-solid fa-sun " aria-hidden="true"></i>
<i v-else-if="theme == 'dark'" class="fa-solid fa-moon " aria-hidden="true"></i>
<!--<i v-else-if="theme == 'purple'" class="fa-solid fa-wine-bottle" aria-hidden="true"></i>-->
</button>
`
}
+112
View File
@@ -0,0 +1,112 @@
import BsPrompt from "../Bootstrap/Prompt.js";
import DashboardAdminEdit from "./Admin/Edit.js";
import DashboardAdminWidgets from "./Admin/Widgets.js";
import DashboardAdminPresets from "./Admin/Presets.js";
export default {
components: {
DashboardAdminEdit,
DashboardAdminWidgets,
DashboardAdminPresets
},
provide() {
return {
adminMode: true
};
},
data() {
return {
dashboards: [],
current: -1,
widgets: []
};
},
computed: {
apiurl() {
return FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/dashboard';
},
dashboard() {
return this.dashboards.find(el => el.dashboard_id == this.current);
}
},
methods: {
dashboardAdd() {
let _name = '';
BsPrompt.popup('New Dashboard name').then(
name => {
_name = name;
return axios.post(this.apiurl + '/Dashboard/create', {
dashboard_kurzbz: name
})
}
).then(res => {
let newDashboard = {
dashboard_id: res.data.retval,
dashboard_kurzbz: _name,
beschreibung: ''
};
this.dashboards.push(newDashboard);
this.current = newDashboard.dashboard_id;
}).catch(err => err !== undefined ? console.error('ERROR:', err) : 0);
},
dashboardUpdate(dashboard) {
// TODO(chris): Loading or message
axios.post(this.apiurl + '/Dashboard/update', dashboard).then(() => {
let old = this.dashboards.find(el => el.dashboard_id == dashboard.dashboard_id);
old.dashboard_kurzbz = dashboard.dashboard_kurzbz;
old.beschreibung = dashboard.beschreibung;
}).catch(err => console.error('ERROR:', err));
},
dashboardDelete(dashboard_id) {
axios.post(this.apiurl + '/Dashboard/delete', {dashboard_id}).then(() => {
this.current = -1;
this.dashboards = this.dashboards.filter(el => el.dashboard_id != dashboard_id);
}).catch(err => console.error('ERROR:', err));
},
assignWidgets(widgets) {
this.widgets = widgets;
/*while (this.widgets.length)
this.widgets.pop();
for (var i in widgets)
this.widgets.push(widgets[i]);*/
}
},
created() {
axios.get(this.apiurl + '/Dashboard').then(res => {
this.dashboards = res.data.retval;
}).catch(err => console.error('ERROR:', err));
},
template: `<div class="dashboard-admin">
<div class="input-group">
<label for="dashbaord-select" class="input-group-text">Dashboard:</label>
<select id="dashbaord-select" class="form-select" v-model="current">
<option v-for="dashboard in dashboards" :key="dashboard.dashboard_id" :value="dashboard.dashboard_id">{{dashboard.dashboard_kurzbz}}</option>
</select>
<button class="btn btn-outline-secondary" type="button" @click="dashboardAdd"><i class="fa-solid fa-plus"></i></button>
</div>
<div v-if="dashboard">
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link" id="edit-tab" data-bs-toggle="tab" data-bs-target="#edit" type="button" role="tab" aria-controls="edit" aria-selected="false">Edit</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link active" id="widgets-tab" data-bs-toggle="tab" data-bs-target="#widgets" type="button" role="tab" aria-controls="widgets" aria-selected="true">Widgets</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="presets-tab" data-bs-toggle="tab" data-bs-target="#presets" type="button" role="tab" aria-controls="presets" aria-selected="false">Presets</button>
</li>
</ul>
<div class="tab-content pt-3">
<div class="tab-pane fade" id="edit" role="tabpanel" aria-labelledby="edit-tab">
<dashboard-admin-edit v-bind="dashboard" :key="dashboard.dashboard_id" @change="dashboardUpdate($event)" @delete="dashboardDelete($event)"></dashboard-admin-edit>
</div>
<div class="tab-pane fade show active" id="widgets" role="tabpanel" aria-labelledby="widgets-tab">
<dashboard-admin-widgets :key="dashboard.dashboard_id" :dashboard_id="dashboard.dashboard_id" :widgets="widgets" @change="test" @assign-widgets="assignWidgets"></dashboard-admin-widgets>
</div>
<div class="tab-pane fade" id="presets" role="tabpanel" aria-labelledby="presets-tab">
<dashboard-admin-presets :dashboard="dashboard.dashboard_kurzbz" :widgets="widgets"></dashboard-admin-presets>
</div>
</div>
</div>
</div>`
}
@@ -0,0 +1,38 @@
import BsConfirm from '../../Bootstrap/Confirm.js';
export default {
emits: [
"change",
"delete"
],
props: {
dashboard_id: Number,
dashboard_kurzbz: String,
beschreibung: String
},
data() {
return {
kurzbz: this.dashboard_kurzbz,
desc: this.beschreibung
}
},
methods: {
sendDelete() {
BsConfirm.popup('Sure?').then(() => this.$emit('delete', this.dashboard_id)).catch();
}
},
template: `<div class="dashboard-admin-edit px-3">
<div class="mb-3">
<label for="dashboard-admin-edit-kurzbz">Kurz Bezeichnung</label>
<input id="dashboard-admin-edit-kurzbz" type="text" class="form-control" v-model="kurzbz">
</div>
<div class="mb-3">
<label for="dashboard-admin-edit-beschreibung">Beschreibung</label>
<textarea id="dashboard-admin-edit-beschreibung" class="form-control" v-model="desc"></textarea>
</div>
<div>
<button class="btn btn-danger" @click="sendDelete">Delete</button>
<button class="btn btn-primary" @click="$emit('change', {dashboard_id,dashboard_kurzbz:kurzbz,beschreibung:desc})">Update</button>
</div>
</div>`
}
@@ -0,0 +1,171 @@
import DashboardSection from "../Section.js";
import DashboardWidgetPicker from "../Widget/Picker.js";
import ObjectUtils from "../../../helpers/ObjectUtils.js";
export default {
components: {
DashboardSection,
DashboardWidgetPicker
},
props: {
dashboard: String,
widgets: Array
},
data: () => ({
funktionen: {},
sections: [],
tmpLoading: ''
}),
computed: {
apiurl() {
return FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/dashboard';
},
pickerWidgets() {
return this.widgets.filter(widget => widget.allowed);
}
},
methods: {
widgetAdd(section_name, widget) {
this.$refs.widgetpicker.getWidget().then(widget_id => {
widget.widget = widget_id;
delete widget.custom;
widget.preset = 1;
let loading = {...widget};
loading.loading = true;
this.sections.forEach(section => {
if (section.name == section_name)
section.widgets.push(loading);
});
axios.post(this.apiurl + '/Config/addWidgetsToPreset', {
db: this.dashboard,
funktion_kurzbz: section_name,
widgets: [widget]
}).then(result => {
let newId = Object.keys(result.data.retval.data[section_name].widgets).pop();
widget.id = newId;
widget.custom = 1;
this.sections.forEach(section => {
if (section.name == section_name) {
section.widgets.splice(section.widgets.indexOf(loading),1);
section.widgets.push(widget);
}
});
}).catch(error => {
console.error('ERROR: ', error);
alert('ERROR: ' + error.response.data.retval);
});
}).catch(() => {});
},
widgetUpdate(section_name, payload) {
payload = payload[section_name];
for (var k in payload) {
for (var i in this.sections) {
if (this.sections[i].name == section_name) {
for (var wid in this.sections[i].widgets) {
if (this.sections[i].widgets[wid].id == k) {
payload[k] = ObjectUtils.mergeDeep(this.sections[i].widgets[wid], payload[k]);
// NOTE(chris): remove internal props
for (var prop in {_x:1,_y:1,_w:1,_h:1,index:1,id:1})
if (payload[k][prop])
delete payload[k][prop];
break;
}
}
break;
}
}
payload[k].widgetid = k;
delete payload[k].custom;
}
axios.post(this.apiurl + '/Config/addWidgetsToPreset', {
db: this.dashboard,
funktion_kurzbz: section_name,
widgets: payload
}).then(() => {
this.sections.forEach(section => {
if (section.name == section_name) {
section.widgets.forEach((widget, i) => {
if (payload[widget.id]) {
payload[widget.id].id = widget.id;
payload[widget.id].index = widget.index;
section.widgets[i] = payload[widget.id];
section.widgets[i].custom = 1;
}
});
}
});
}).catch(error => {
// TODO(chris): revert placement on failure
console.error('ERROR: ', error);
alert('ERROR: ' + error.response.data.retval);
});
},
widgetRemove(section_name, id) {
axios.post(this.apiurl + '/Config/removeWidgetFromPreset', {
db: this.dashboard,
funktion_kurzbz: section_name,
widgetid: id
}).then(() => {
this.sections.forEach(section => {
if (section.name == section_name)
section.widgets = section.widgets.filter(widget => widget.id != id);
});
}).catch(error => {
console.error('ERROR: ', error);
alert('ERROR: ' + error.response.data.retval);
});
},
loadSections(evt) {
let funktionen = Array.from(evt.target.querySelectorAll("option:checked"),e=>e.value);
this.sections = [];
this.tmpLoading = funktionen.join('###');
axios.get(this.apiurl + '/Config/presetBatch', {params: {
db: this.dashboard,
funktionen
}}).then(res => {
if (this.tmpLoading !== funktionen.join('###'))
return; // NOTE(chris): prevent race condition
for (var section in res.data.retval) {
let widgets = [];
for (var wid in res.data.retval[section]) {
res.data.retval[section][wid].id = wid;
res.data.retval[section][wid].custom = 1;
widgets.push(res.data.retval[section][wid]);
}
this.sections.push({
name: section,
widgets
});
}
}).catch(err => console.error('ERROR:', err));
}
},
created() {
axios.get(this.apiurl + '/Config/funktionen').then(res => {
this.funktionen = {general: 'GENERAL'};
res.data.retval.forEach(funktion => {
this.funktionen[funktion.funktion_kurzbz] = funktion.beschreibung;
});
}).catch(err => console.error('ERROR:', err));
},
watch: {
dashboard() {
// TODO(chris): this should be done without a watcher
this.loadSections({target:this.$refs.funktionenList});
}
},
template: `<div class="dashboard-admin-presets">
<div class="row">
<div class="col-3">
<select ref="funktionenList" style="height:30em" class="form-control" multiple @input="loadSections">
<option v-for="name,id in funktionen" :key="id" :value="id">{{ name }}</option>
</select>
</div>
<div class="col-9">
<dashboard-section v-for="section in sections" :key="section.name" :name="section.name" :widgets="section.widgets" @widget-add="widgetAdd" @widget-update="widgetUpdate" @widget-remove="widgetRemove"></dashboard-section>
</div>
</div>
<dashboard-widget-picker ref="widgetpicker" :widgets="pickerWidgets"></dashboard-widget-picker>
</div>`
}
@@ -0,0 +1,46 @@
export default {
emits: [
"change",
"assignWidgets"
],
props: {
dashboard_id: Number,
widgets: Array
},
computed: {
apiurl() {
return FHC_JS_DATA_STORAGE_OBJECT.app_root + FHC_JS_DATA_STORAGE_OBJECT.ci_router + '/dashboard';
}
},
methods: {
sendChange(widget_id) {
let allow = !this.widgets.find(el => el.widget_id == widget_id).allowed;
axios.post(this.apiurl + '/Widget/setAllowed', {
dashboard_id: this.dashboard_id,
widget_id,
action: allow ? 'add' : 'delete'
}).catch(err => console.error('ERROR: ' + err));
}
},
created() {
axios.get(this.apiurl + '/Widget/getAll', {
params:{
dashboard_id: this.dashboard_id
}
}).then(
result => {
this.$emit('assignWidgets', result.data.retval.map(el => ({
...el,
...{setup:JSON.parse(el.setup),arguments:JSON.parse(el.arguments),allowed:!!el.allowed}
})));
}
).catch(err => console.error('ERROR:', err));
},
template: `
<div class="dashboard-admin-widgets">
<div v-for="widget in widgets" :key="widget.widget_id" class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" :id="'dashboard-admin-widgets-' + widget.widget_id" v-model="widget.allowed" @input.prevent="sendChange(widget.widget_id)">
<label class="form-check-label" :for="'dashboard-admin-widgets-' + widget.widget_id">{{(widget.setup && widget.setup.name) || widget.widget_kurzbz}}</label>
</div>
</div>`
}

Some files were not shown because too many files have changed in this diff Show More