/** * Copyright (C) 2024 fhcomplete.org * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import CoreSearchbar from "../searchbar/searchbar.js"; import NavLanguage from "../navigation/Language.js"; import VerticalSplit from "../verticalsplit/verticalsplit.js"; import FhcCalendar from "../Calendar/Tempus.js"; import FhcCoursepicker from "../Tempus/Coursepicker.js"; import LectureSelection from "../Tempus/LectureSelection.js"; import ParkingSlot from "../Tempus/ParkingSlot.js"; import ApiKalender from '../../api/factory/tempus/kalender.js'; import ApiSearchbar from "../../api/factory/searchbar.js"; import ApiRenderers from '../../api/factory/renderers.js'; import ApiTempusConfig from '../../api/factory/tempus/config.js'; import AppMenu from "../AppMenu.js"; import drop from '../../directives/drop.js'; import AppConfig from "../AppConfig.js"; import BsModal from "../Bootstrap/Modal.js"; import StvVerband from "../Stv/Studentenverwaltung/Verband.js"; import ApiStudiengangTree from "../../api/lehrveranstaltung/studiengangtree.js"; import StvStudiensemester from "../Stv/Studentenverwaltung/Studiensemester.js"; import FormInput from "../../../js/components/Form/Input.js"; import Reservierung from "./Reservierung.js"; export default { name: "Tempus", components: { CoreSearchbar, VerticalSplit, FhcCalendar, FhcCoursepicker, LectureSelection, ParkingSlot, AppConfig, AppMenu, NavLanguage, BsModal, StvVerband, StvStudiensemester, Multiselect: primevue.multiselect, FormInput, Reservierung }, props: { defaultSemester: String, config: Object, permissions: Object, tempusRoot: String, cisRoot: String, activeAddons: String, // semicolon separated list of active addons viewData: Object, logoutUrl: String, avatarUrl: String }, directives: { drop }, provide() { return { cisRoot: this.cisRoot, defaultSemester: this.defaultSemester, currentSemester: this.defaultSemester, renderers: Vue.computed(() => this.renderers), appConfig: Vue.computed(() => this.appconfig), contextMenuActions: Vue.computed(() => this.contextMenuActions), } }, data() { return { appconfig: {}, configEndpoints: ApiTempusConfig, endpoint: ApiStudiengangTree, raumVorschlaege: [], selected: [], searchbaroptions: { origin: 'tempus', cssclass: "position-relative", calcheightonly: true, types: [ //"student", "raum", "mitarbeiter", "mitarbeiter_ohne_zuordnung" ], actions: { raum: { defaultaction: { type: "function", action: this.setOrt }, childactions: [ ] }, employee: { defaultaction: { type: "function", action: (data) => { this.setEmp(data); } }, childactions: [ ] }, } }, lv_id: null, events: null, minimized: false, currentlySelectedEvent: null, //currentDay: new Date(), studiensemesterKurzbz: this.defaultSemester, lists: { nations: [], sprachen: [], geschlechter: [] }, renderers: null, ort_kurzbz: null, view: 'room', parkedKeys: new Set(), lecturers: [], overlayCache: [], extraBackgrounds: [], lastRange: null, stg: null, show_stg: null, semester: null, studiensemester_kurzbz: null, raumModal: { show: false, loading: false, vorschlaege: [], event: null }, visibleStatusArray: {}, visibleStatus: ['all'], selectedStudiensemester: this.studiensemester_kurzbz ?? this.defaultSemester, calendarDate: luxon.DateTime.now().setZone(this.config.timezone).toISODate(), historyEntries: [], previewRole: 'planer' } }, computed: { contextMenuActions() { return { lehreinheit: [ { label: 'Raumauswahl', icon: 'fa-solid fa-door-open', action: this.openRaumauswahl }, { label: 'Freischalten für Voransicht', icon: 'fa-solid fa-chalkboard-user', action: (orig) => this.$api.call(ApiKalender.syncToLecturer(orig.kalender_id)).then(() => this.$refs.calendar.resetEventLoader()) }, { label: 'Freischalten für Live', icon: 'fa-solid fa-user-graduate', action: (orig) => this.$api.call(ApiKalender.syncToStudent(orig.kalender_id)).then(() => this.$refs.calendar.resetEventLoader()) }, { label: 'History', icon: 'fa-solid fa-clock-rotate-left', action: this.openHistory }, { label: 'Delete', icon: 'fa-solid fa-calendar-xmark', action: this.deleteEntry }, ], reservierung: [ { label: 'Delete', icon: 'fa-solid fa-calendar-xmark', action: this.deleteEntry }, ] }; }, currentDay() { return luxon.DateTime.now().setZone(this.config.timezone).toISODate(); }, currentMode() { return 'week'; }, visibleLecturerUids() { if (!this.lecturers.length) return null; return this.lecturers.filter(lecture => lecture.showEvents).map(lecture => lecture.uid); }, visibleStatusOptions() { return Object.entries(this.visibleStatusArray).map(([key, label]) => ({ key, label })); }, visibleStatusValue() { if (this.visibleStatus.includes('all')) return this.visibleStatusOptions.filter(visibleStatus => visibleStatus.key === 'all'); return this.visibleStatus.map(status => ({ key: status, label: this.visibleStatusArray[status] })); }, }, methods: { async openRaumauswahl(orig) { if (!orig?.lehreinheit_id) return; this.raumModal = orig; await this.$api.call(ApiKalender.getRaumvorschlag( orig.isostart, orig.isoend, orig.lehreinheit_id[0] )).then(result => { this.raumVorschlaege = result.data ?? []; this.$refs.raumModal.show(); }); }, async deleteEntry(orig) { if (!orig?.kalender_id) return; await this.$api.call(ApiKalender.deleteEntry( orig?.kalender_id )).then(result => { this.$refs.calendar.resetEventLoader(); }); }, async openHistory(orig) { if (!orig?.kalender_id) return; await this.$api.call(ApiKalender.getHistory( orig.kalender_id )).then(result => { this.historyEntries = result.data ?? []; this.$refs.historyModel.show(); }); }, async selectRaum(ort_kurzbz) { const orig = this.raumModal; await this.$api.call( ApiKalender.updateKalenderEvent(orig.kalender_id, { ort_kurzbz, start_time: orig.von, end_time: orig.bis })).then(() => this.$refs.raumModal.hide()); this.$refs.calendar.resetEventLoader(); }, setOrt: function(data) { this.ort_kurzbz = data.ort_kurzbz; this.$refs.calendar.resetEventLoader(); }, onSelectVerbandAndClose(payload) { this.onSelectVerband(payload); bootstrap.Offcanvas.getOrCreateInstance(this.$refs.verbandMenu).hide(); }, onSelectVerband({link, name}) { let stg = null; let semester = null; this.show_stg = name if (typeof link === 'number') stg = link; else if (typeof link === 'string') { [stg, semester] = link.split('/'); } this.stg = stg; if (semester !== null) this.semester = semester; this.$refs.calendar.resetEventLoader(); }, setEmp: function(data) { const uid = data.uid; const label = data.name; if (!this.lecturers.some(l => l.uid === uid)) { this.lecturers.push({ uid, label, showEvents: true, overlays: { blocks: true, wishes: true }, }); } this.$refs.calendar.resetEventLoader(); if (this.lastRange) this.handleRange(this.lastRange); }, jumpToKw(kw) { const num = parseInt(kw); if (!num) return; const date = luxon.DateTime.fromObject({ weekYear: luxon.DateTime.now().setZone(this.config.timezone).weekYear, weekNumber: num, weekday: 1, }, { zone: this.config.timezone }); this.calendarDate = date.toISODate(); }, handleChangeDate(newDate) { if (newDate && luxon.DateTime.isDateTime(newDate) && newDate.isValid) this.calendarDate = newDate.toISODate(); }, handleChangeMode() { console.log("handleChangeMode") }, toggleStatus(selected) { if (!selected || selected.length === 0) { this.visibleStatus = ['all']; return; } const hasAll = selected.includes('all'); const hadAll = this.visibleStatus.includes('all'); if (hasAll && !hadAll) { this.visibleStatus = ['all']; return; } this.visibleStatus = selected.filter(k => k !== 'all'); if (this.visibleStatus.length === 0) this.visibleStatus = ['all']; }, searchfunction(params) { return this.$api.call(ApiSearchbar.search(params)); }, getPromiseFunc(start, end) { const hasRoom = !!this.ort_kurzbz; const hasLektoren = this.lecturers.length > 0; const hasStg = !!this.stg; const filter = {}; if (hasRoom) filter.ort = this.ort_kurzbz; if (hasStg) filter.stg = this.stg; if (hasLektoren) filter.uid = this.lecturers.map(l => l.uid); if (this.previewRole === 'lektor') return [this.$api.call(ApiKalender.getPlanLecturer(start.toISODate(), end.toISODate()))]; if (this.previewRole === 'student') return [this.$api.call(ApiKalender.getPlanStudent(start.toISODate(), end.toISODate()))]; return [this.$api.call(ApiKalender.getPlan(filter, start.toISODate(), end.toISODate()))]; }, toDateTime(value, timezone){ if (luxon.DateTime.isDateTime(value)) return value; if (value?.date?.isValid) return value.date; if (typeof value === 'number') return luxon.DateTime.fromMillis(value, { zone: timezone }); if (value instanceof Date) return luxon.DateTime.fromJSDate(value, { zone: timezone }); if (typeof value === 'string') return luxon.DateTime.fromISO(value, { zone: timezone }); return luxon.DateTime.invalid("invalid datetime"); }, getLastEndOfSameDay(startDT, ends) { if (!ends?.length) return null; const dayKey = startDT.toISODate(); let lastSameDay = null; for (const end of ends) { const dt = luxon.DateTime.isDateTime(end) ? end : luxon.DateTime.fromISO(String(end), { zone: startDT.zoneName }); if (!dt.isValid) continue; if (dt.toISODate() === dayKey) lastSameDay = dt; } return lastSameDay; }, clampEndToGrid(startDT, durationMin, ends) { const calculatedEnd = startDT.plus({ minutes: durationMin }); const lastGridEndSameDay = this.getLastEndOfSameDay(startDT, ends); if (!lastGridEndSameDay) return calculatedEnd; return calculatedEnd > lastGridEndSameDay ? lastGridEndSameDay : calculatedEnd; }, _parseDates(start, end) { const startDT = luxon.DateTime.fromISO(start); const endDT = luxon.DateTime.fromISO(end); if (!startDT.isValid || !endDT.isValid) { alert("Ungültiges Datum"); return null; } return { startDT, endDT, start_time: startDT.toFormat('yyyy-MM-dd HH:mm'), end_time: endDT.toFormat('yyyy-MM-dd HH:mm'), }; }, _updateKalenderEvent(obj, startDT, endDT, start_time, end_time, onSuccess) { const origStart = luxon.DateTime.fromISO(obj.orig.isostart); const origEnd = luxon.DateTime.fromISO(obj.orig.isoend); if (origStart.toMillis() === startDT.toMillis() && origEnd.toMillis() === endDT.toMillis()) return; const updatedInfos = { ort_kurzbz: this.ort_kurzbz ? this.ort_kurzbz : obj.orig.ort_kurzbz, start_time, end_time, }; return this.$api.call(ApiKalender.updateKalenderEvent(obj.orig.kalender_id, updatedInfos)) .then(() => { if (onSuccess) onSuccess(); }); }, resizeHandler(payload) { if (this.previewRole !== 'planer') //TODO (david) testzweck return; const { item, start, end } = payload; const obj = item[0]; if (!obj?.orig?.kalender_id) return alert("Kein gültiges Kalender-Event zum Resizen"); const dates = this._parseDates(start, end); if (!dates) return; return this._updateKalenderEvent(obj, dates.startDT, dates.endDT, dates.start_time, dates.end_time, () => { this.$refs.calendar.resetEventLoader(); }); }, dropHandler(payload) { if (this.previewRole !== 'planer') //TODO (david) testzweck return; const { item, start, end } = payload; if (!item?.length) return alert("Keine Daten gedroppt"); const obj = item[0]; if (!obj?.type) return alert("Unbekannter Drop-Typ"); const dates = this._parseDates(start, end); if (!dates) return; const { startDT, endDT, start_time, end_time } = dates; if (obj.type === 'reservierung') { this.reservierungPending = true; this.$refs.reservierung.show(start_time, end_time); } else if (obj.type === 'lehreinheit') { return this.$api.call( ApiKalender.addKalenderEvent( obj.orig.lehreinheit_id, this.ort_kurzbz ? this.ort_kurzbz : obj.orig.ort_kurzbz, start_time, end_time ) ).then(() => { this.$refs.calendar.resetEventLoader(); this.bcc.postMessage('dropped'); }); } else if (obj.type === 'kalender') { return this._updateKalenderEvent(obj, startDT, endDT, start_time, end_time, () => { this.$refs.parking.unpark({ type: obj.type, id: obj.orig.kalender_id }); this.$refs.calendar.resetEventLoader(); this.bcc.postMessage('dropped'); }); } else { alert("Unbekannter Drop-Typ: " + obj.type); } }, handleRange(range) { if (!range?.start || !range?.end) return; if (this.currentMode === 'week') { //Workaround because, updateRange is emitting 2 times const startDay = range.start.startOf('day'); const endDay = range.end.startOf('day'); const days = Math.round(endDay.diff(startDay, 'days').days) + 1; if (days > 8) return; } this.lastRange = range; const key = `${range.start.toISODate()}_${range.end.toISODate()}_${this.currentMode}`; for (const lect of this.lecturers) { this.getOverlays(lect.uid, range, key); } this.rebuildExtraBackgrounds(); }, getOverlays(uid, range, rangeKey) { if (!this.overlayCache[uid]) this.overlayCache[uid] = {}; let entry = this.overlayCache[uid][rangeKey]; if (entry?.loaded || entry?.loading) return; entry = this.overlayCache[uid][rangeKey] = { blocks: [], wishes: [], loading: true, loaded: false }; const promises = []; const lect = this.lecturers.find(lecture => lecture.uid === uid); if (lect.overlays.wishes) { promises.push( this.$api.call(ApiKalender.getLektorZeitwuensche(uid, range.start.toISODate(), range.end.toISODate())) .then(result => { entry.wishes = (result.data || []).map(zeitwunsch => ({ class: `bg-lecturer-wish bg-uid-${uid} wish-w-${zeitwunsch.gewicht}`, start: zeitwunsch.isostart, end: zeitwunsch.isoend, label: zeitwunsch.label })); }) ); } if (lect.overlays.blocks) { promises.push( this.$api.call(ApiKalender.getLektorZeitsperren(uid, range.start.toISODate(), range.end.toISODate())) .then(result => { entry.blocks = (result.data || []).map(zeitsperre => ({ class: `bg-lecturer-block bg-uid-${uid}`, start: zeitsperre.isostart, end: zeitsperre.isoend, label: zeitsperre.label })); }) ); } Promise.allSettled(promises).then(() => { entry.loading = false; entry.loaded = true; this.rebuildExtraBackgrounds(); }); }, rebuildExtraBackgrounds() { if (!this.lastRange) return; const key = `${this.lastRange.start.toISODate()}_` + `${this.lastRange.end.toISODate()}_` + `${this.currentMode}`; let res = []; for (let lect of this.lecturers) { const entry = this.overlayCache[lect.uid]?.[key]; if (!entry) continue; if (lect.overlays.blocks) res.push(...(entry.blocks || [])); if (lect.overlays.wishes) res.push(...(entry.wishes || [])); } this.extraBackgrounds = res; }, removeLecturer(uid) { this.lecturers = this.lecturers.filter(lecture => lecture.uid !== uid); delete this.overlayCache[uid]; this.$refs.calendar.resetEventLoader(); }, clearOrt() { this.ort_kurzbz = null; this.$refs.calendar.resetEventLoader(); }, clearStg() { this.stg = null; this.show_stg = null; this.$refs.calendar.resetEventLoader(); }, triggerSync() { this.$api.call(ApiKalender.sync()).then(this.$refs.calendar.resetEventLoader()) } }, watch: { lecturers: { deep: true, handler() { this.rebuildExtraBackgrounds(); } } }, mounted() { this.reservierungPending = false; this.bcc = new BroadcastChannel('fhc-dnd'); this.bcc.addEventListener('message', e => { if (e.data === 'dropped' && !this.reservierungPending) this.$refs.calendar.resetEventLoader(); }); }, beforeUnmount() { this.bcc.close(); }, async created() { await this.$api .call(ApiRenderers.loadTempusRenderers()) .then(res => res.data) .then(data => { for (let rendertype of Object.keys(data)) { let modalTitle = null; let modalContent = null; let calendarEvent = null; if (data[rendertype].modalTitle) modalTitle = Vue.markRaw(Vue.defineAsyncComponent(() => import(data[rendertype].modalTitle))); if (data[rendertype].modalContent) modalContent = Vue.markRaw(Vue.defineAsyncComponent(() => import(data[rendertype].modalContent))); if (data[rendertype].calendarEvent) calendarEvent = Vue.markRaw(Vue.defineAsyncComponent(() => import(data[rendertype].calendarEvent))); if (data[rendertype].calendarEventStyles){ var head = document.head; if(!head.querySelector(`link[href="${data[rendertype].calendarEventStyles}"]`)){ var link = document.createElement("link"); link.type = "text/css"; link.rel = "stylesheet"; link.href = data[rendertype].calendarEventStyles; head.appendChild(link); } } if(this.renderers === null) { this.renderers = {}; } if (!this.renderers[rendertype]) { this.renderers[rendertype] = {} } this.renderers[rendertype].modalTitle = modalTitle; this.renderers[rendertype].modalContent = modalContent; this.renderers[rendertype].calendarEvent = calendarEvent; } }); this.$api.call(ApiTempusConfig.getHeader()) .then(res => { this.visibleStatusArray = res.data.visible_status; this.visibleStatus = ['all']; }); }, template: `
Verband
` };