From 8bac1c1ebb95c84e760a56f5ecb33d9894142670 Mon Sep 17 00:00:00 2001 From: Ivymaster Date: Tue, 15 Sep 2026 14:23:15 +0200 Subject: [PATCH] WIP: Special view for page printing --- application/controllers/Tempus.php | 5 + application/libraries/CollisionChecker.php | 35 +- application/libraries/KalenderLib.php | 2 +- public/js/apps/Tempus.js | 2 + public/js/components/Calendar/Mode/Range.js | 19 +- public/js/components/Calendar/Tempus.js | 69 +- public/js/components/Tempus/Tempus.js | 13 + .../Tempus/TempusCalendarPreview.js | 663 ++++++++++++++++++ .../composables/Tempus/TempusEventLoader.js | 137 ++-- 9 files changed, 865 insertions(+), 80 deletions(-) create mode 100644 public/js/components/Tempus/TempusCalendarPreview.js diff --git a/application/controllers/Tempus.php b/application/controllers/Tempus.php index a10c74909..2b72bece5 100644 --- a/application/controllers/Tempus.php +++ b/application/controllers/Tempus.php @@ -55,6 +55,11 @@ class Tempus extends Auth_Controller $this->_loadView(array('organisationen' => $organisationen, 'studiensemestern' => $studiensemestern)); } + public function preview() + { + $this->_loadView(); + } + private function _loadView($extraVariables = []) { $this->load->view('Tempus', [ diff --git a/application/libraries/CollisionChecker.php b/application/libraries/CollisionChecker.php index 9338745bc..f3ffb056e 100644 --- a/application/libraries/CollisionChecker.php +++ b/application/libraries/CollisionChecker.php @@ -16,14 +16,14 @@ class CollisionChecker $this->_ci->load->library('collision/checks/RoomCollisionCheck'); $this->_ci->load->library('collision/checks/LectureCollisionCheck'); + $this->_ci->load->library('collision/checks/ResourcesCollisionCheck'); $this->_ci->load->library('collision/checks/VerbandCollisionCheck'); $this->_ci->load->library('collision/checks/StudentCollisionCheck'); - $this->_ci->load->library('collision/checks/ResourcesCollisionCheck'); $this->register($this->_ci->roomcollisioncheck); $this->register($this->_ci->lecturecollisioncheck); + $this->register($this->_ci->resourcescollisioncheck); $this->register($this->_ci->verbandcollisioncheck); $this->register($this->_ci->studentcollisioncheck); - $this->register($this->_ci->resourcescollisioncheck); Events::trigger('collision_register', $this); } @@ -50,41 +50,28 @@ class CollisionChecker } public function runAll($kalender_ids) - { - $results = array_fill_keys($kalender_ids, []); - - foreach ($this->_checks as $check) - { - $batchResult = $check->checkAll($kalender_ids); - foreach ($batchResult as $kalender_id => $errors) - { - $results[$kalender_id] = array_merge($results[$kalender_id], $errors); - } - } - - return $results; - } - - public function runAny($kalender_ids) { $remaining_ids = array_values(array_unique($kalender_ids)); + $results = array_fill_keys($remaining_ids, []); $collision_ids = []; foreach ($this->_checks as $check) { if (empty($remaining_ids)) break; - - $batch_result = $check->checkAll($remaining_ids); - foreach ($batch_result as $kalender_id => $errors) + log_message('error', 'CollisionChecker: Running check ' . $check->getName() . ' for ' . count($remaining_ids) . ' remaining kalender_ids'); + $batchResult = $check->checkAll($remaining_ids); + foreach ($batchResult as $kalender_id => $errors) { - if (!empty($errors)) - $collision_ids[$kalender_id] = true; + if (empty($errors)) continue; + + $results[$kalender_id] = array_merge($results[$kalender_id], $errors); + $collision_ids[$kalender_id] = true; } if (!empty($collision_ids)) $remaining_ids = array_values(array_diff($remaining_ids, array_keys($collision_ids))); } - return $collision_ids; + return $results; } } diff --git a/application/libraries/KalenderLib.php b/application/libraries/KalenderLib.php index 382af26c1..d49e37a4d 100644 --- a/application/libraries/KalenderLib.php +++ b/application/libraries/KalenderLib.php @@ -845,7 +845,7 @@ class KalenderLib foreach ($kalender_id_batches as $kalender_ids) { - $collision_ids = $this->_ci->collisionchecker->runAny($kalender_ids); + $collision_ids = $this->_ci->collisionchecker->runAll($kalender_ids); foreach ($collision_ids as $kalender_id => $has_collision) { diff --git a/public/js/apps/Tempus.js b/public/js/apps/Tempus.js index b1159da02..de8b31b3d 100644 --- a/public/js/apps/Tempus.js +++ b/public/js/apps/Tempus.js @@ -16,6 +16,7 @@ */ import FhcTempus from "../components/Tempus/Tempus.js"; +import FhcTempusCalendarPreview from "../components/Tempus/TempusCalendarPreview.js"; import FhcTempusSync from "../components/Tempus/TempusSync.js"; import Phrasen from "../plugins/Phrasen.js"; @@ -28,6 +29,7 @@ const router = VueRouter.createRouter({ history: VueRouter.createWebHistory(), routes: [ { path: `/${ciPath}/Tempus`, component: FhcTempus }, + { path: `/${ciPath}/Tempus/preview`, component: FhcTempusCalendarPreview }, { path: `/${ciPath}/Tempus/sync`, component: FhcTempusSync }, ] }); diff --git a/public/js/components/Calendar/Mode/Range.js b/public/js/components/Calendar/Mode/Range.js index 29693aa0d..d51e4a5d5 100644 --- a/public/js/components/Calendar/Mode/Range.js +++ b/public/js/components/Calendar/Mode/Range.js @@ -16,6 +16,9 @@ export default { rangeViewPresets: { default: {}, }, + rangeViewPreviewLink: { + default: null, + }, }, props: { currentDate: { @@ -133,12 +136,13 @@ export default { @cal-click-default.capture="handleClickDefaults" >
- {{ rangeViewPresets.label }} + {{ rangeViewPresets.label }} + + +
diff --git a/public/js/components/Calendar/Tempus.js b/public/js/components/Calendar/Tempus.js index 693ec6e85..e53f57e32 100644 --- a/public/js/components/Calendar/Tempus.js +++ b/public/js/components/Calendar/Tempus.js @@ -10,6 +10,17 @@ import ApiKalender from '../../api/factory/tempus/kalender.js'; import draggable from '../../directives/draggable.js'; import ApiStudiensemester from '../../api/factory/studiensemester.js'; +function getRangeLength(range) { + if (!(range instanceof luxon.Interval) || !range.isValid) + return null; + + const start = range.start.startOf('day'); + const end = range.end.startOf('day'); + if (end < start) return null; + + return Math.floor(end.diff(start, 'days').days) + 1; +} + export default { name: 'CalendarTempus', components: { @@ -19,6 +30,7 @@ export default { return { rangeLength: Vue.computed(() => this.currentRangeLength), rangeViewPresets: Vue.computed(() => this.semesterRangePresets), + rangeViewPreviewLink: Vue.computed(() => this.rangeViewPreviewLink), // rangeViewSelectedPreset: Vue.computed({ // get: () => this.selectedRangePreset, // set: value => this.selectedRangePreset = value, @@ -61,10 +73,18 @@ export default { type: [Date, String, Number, luxon.DateTime], default: luxon.DateTime.local(), }, + range: { + type: luxon.Interval, + default: null, + }, mode: { type: String, default: 'Week', }, + modes: { + type: Array, + default: () => ['week', 'month', 'tableList', 'range'], + }, getPromiseFunc: { type: Function, required: true, @@ -92,6 +112,22 @@ export default { showEvents: { type: Boolean, default: true + }, + isEventDraggingEnabled: { + type: Boolean, + default: true, + }, + isEventResizingEnabled: { + type: Boolean, + default: true, + }, + isRangeVirtualScrollEnabled: { + type: Boolean, + default: true, + }, + rangeViewPreviewLink: { + type: String, + default: null, } }, emits: [ @@ -111,7 +147,7 @@ export default { visibleDates: null, eventReloadKey: 0, refreshEventsAfterReload: false, - modes: { + calendarModes: { week: Vue.markRaw(ModeWeek), month: Vue.markRaw(ModeMonth), tableList: Vue.markRaw(ModeTable), @@ -130,7 +166,7 @@ export default { teachingunits: null, hoursplan: null, showRaster: true, - currentRangeLength: this.rangeLength, + currentRangeLength: getRangeLength(this.range) ?? this.rangeLength, selectedRangePreset: null, //this.currentSemester, TODO: This is a hack to make the range mode work, but it should be fixed in the future semesterRangePresets: { label: null, @@ -139,6 +175,10 @@ export default { }; }, watch: { + range(range) { + const rangeLength = getRangeLength(range); + this.currentRangeLength = rangeLength ?? this.rangeLength; + }, events() { if (this.refreshEventsAfterReload) { this.eventReloadKey += 1; @@ -149,6 +189,14 @@ export default { } }, computed: { + availableModes() { + return this.modes.reduce((availableModes, mode) => { + if (this.calendarModes[mode]) + availableModes[mode] = this.calendarModes[mode]; + + return availableModes; + }, {}); + }, backgrounds() { let now = luxon.DateTime.now().setZone(this.timezone); @@ -180,7 +228,11 @@ export default { // that is visible inside the slider viewport, including a one-day buffer // before and after the visible dates. console.log('visibleDates', this.visibleDates); - if (this.currentMode === 'range' && Array.isArray(this.visibleDates)) + if ( + this.isRangeVirtualScrollEnabled + && this.currentMode === 'range' + && Array.isArray(this.visibleDates) + ) { const visibleIntervals = this.visibleDates .map(date => luxon.Interval.fromDateTimes( @@ -360,7 +412,7 @@ export default { ref="calendar" class="fhc-calendar-lvplan" :date="date" - :modes="modes" + :modes="availableModes" :mode-options="modeOptions" :mode="mode" :timezone="timezone" @@ -371,10 +423,10 @@ export default { :time-grid="showRaster ? teachingunits : null" :hours-plan="hoursplan" show-btns - :draggable-events="true" - :resizable-events="true" - :on-drop="['week', 'range'].includes(currentMode) ? ondrop : null" - :on-resize="onresize" + :draggable-events="isEventDraggingEnabled" + :resizable-events="isEventResizingEnabled" + :on-drop="isEventDraggingEnabled && ['week', 'range'].includes(currentMode) ? ondrop : null" + :on-resize="isEventResizingEnabled ? onresize : null" @update:date="handleDateUpdate" @update:mode="(newMode, newDate) => { currentMode = newMode; $emit('update:mode', newMode, newDate) }" @update:range="updateRange" @@ -418,6 +470,7 @@ export default { Stundenraster
lecture.showEvents) .map((lecture) => lecture.uid); }, + rangeViewPreviewLink() { + const previewUrl = `${this.tempusRoot.replace(/\/$/, '')}/preview`; + if (!this.lastRange?.start || !this.lastRange?.end) + return previewUrl; + + const params = new URLSearchParams({ + startDate: this.lastRange.start.toISODate(), + endDate: this.lastRange.end.toISODate(), + }); + + return `${previewUrl}?${params}`; + }, keyboardShortcuts() { return getTempusShortcuts(this); }, @@ -1221,6 +1233,7 @@ export default { :visible-status="visibleStatus" :date="calendarDate" :mode="currentMode" + :range-view-preview-link="rangeViewPreviewLink" :parkedEvents="parkedKeys" :visible-lecturers="visibleLecturerUids" :show-events="showEvents" diff --git a/public/js/components/Tempus/TempusCalendarPreview.js b/public/js/components/Tempus/TempusCalendarPreview.js new file mode 100644 index 000000000..c65a91286 --- /dev/null +++ b/public/js/components/Tempus/TempusCalendarPreview.js @@ -0,0 +1,663 @@ +/** + * 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 FhcCalendar from '../Calendar/Tempus.js'; +import ApiKalender from '../../api/factory/tempus/kalender.js'; +import ApiRenderers from '../../api/factory/renderers.js'; +import ApiTempusConfig from '../../api/factory/tempus/config.js'; + +function getRangeFromQuery(query, timezone) { + const startDate = Array.isArray(query?.startDate) + ? query.startDate[0] + : query?.startDate; + const endDate = Array.isArray(query?.endDate) + ? query.endDate[0] + : query?.endDate; + const start = luxon.DateTime.fromISO(startDate ?? '', { + zone: timezone, + }).startOf('day'); + const end = luxon.DateTime.fromISO(endDate ?? '', { + zone: timezone, + }).endOf('day'); + + if (!start.isValid || !end.isValid || end < start) + return null; + + return luxon.Interval.fromDateTimes(start, end); +} + +export default { + name: 'TempusCalendarPreview', + components: { + FhcCalendar, + }, + 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, + }, + provide() { + return { + cisRoot: this.cisRoot, + defaultSemester: this.defaultSemester, + currentSemester: this.defaultSemester, + renderers: Vue.computed(() => this.renderers), + appConfig: Vue.computed(() => this.appconfig), + canToggleGrid: this.permissions.stundenraster, + }; + }, + data() { + const today = luxon.DateTime.now().setZone(this.config.timezone).toISODate(); + + return { + appconfig: {}, + currentMode: 'range', + configEndpoints: ApiTempusConfig, + hoveredEvent: null, + renderers: {}, + ort_kurzbz: null, + parkedKeys: new Set(), + lecturers: [], + studiengaenge: [], + rooms: [], + overlayCache: {}, + extraBackgrounds: [], + lastRange: null, + visibleStatus: ['all'], + selectedStudiensemester: this.defaultSemester, + urlRange: null, + calendarDatesByMode: { + week: today, + month: today, + tableList: today, + range: today, + }, + historyEntries: [], + previewRole: 'planer', + multiWeekModal: { + show: false, + lehreinheitId: null, + ortKurzbz: null, + startTime: null, + endTime: null, + }, + studiengaenge_all: [], + maxDailyEventLimitForMonthView: 10, + raumvorschlagPreview: null, + raumvorschlagLoading: false, + showEvents: true, + reservierungPending: false, + bcc: null, + currentlyUpdatedEvent: null, + }; + }, + computed: { + calendarDate() { + return ( + this.calendarDatesByMode[this.currentMode] ?? + this.calendarDatesByMode.week + ); + }, + visibleLecturerUids() { + if (!this.lecturers.length) return null; + return this.lecturers + .filter((lecture) => lecture.showEvents) + .map((lecture) => lecture.uid); + }, + }, + methods: { + handleChangeDate(newDate) { + if (!(newDate && luxon.DateTime.isDateTime(newDate) && newDate.isValid)) + return; + + this.calendarDatesByMode = { + ...this.calendarDatesByMode, + [this.currentMode]: newDate.toISODate(), + }; + }, + handleChangeMode(newMode, newDate) { + if (!newMode) return; + + if (newDate && luxon.DateTime.isDateTime(newDate) && newDate.isValid) { + this.calendarDatesByMode = { + ...this.calendarDatesByMode, + [this.currentMode]: newDate.toISODate(), + }; + } + + this.currentMode = newMode; + }, + getPromiseFunc(start, end) { + const collisionCheck = false; + const hasRooms = this.rooms.length > 0; + const hasLektoren = this.lecturers.length > 0; + const hasStg = this.studiengaenge.length > 0; + + const filter = {}; + + if (hasRooms) filter.ort = this.rooms.map((room) => room.ort_kurzbz); + if (hasStg) { + filter.stg = this.studiengaenge.map( + ({ stg_kz, semester, orgform_kurzbz }) => ({ + stg_kz, + semester, + orgform_kurzbz, + }), + ); + } + if (hasLektoren) + filter.uid = this.lecturers.map((lecture) => lecture.uid); + + let response = null; + if (this.previewRole === 'lektor') + response = [ + this.$api.call( + ApiKalender.getPlanLecturer( + start.toISODate(), + end.toISODate(), + collisionCheck, + + ), + ), + ]; + + if (this.previewRole === 'student') + response = [ + this.$api.call( + ApiKalender.getPlanStudent( + start.toISODate(), + end.toISODate(), + collisionCheck, + + ), + ), + ]; + + response = [ + this.$api.call( + ApiKalender.getPlan( + filter, + start.toISODate(), + end.toISODate(), + collisionCheck, + + ), + ), + ]; + + if (response) { + response[0].then((result) => { + this.scrollToAndEmphasizeUpdatedEvent(); + }); + } + + return response; + }, + _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.rooms.length + ? this.rooms.map((room) => room.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(); + this.$refs.calendar.$refs.calendar.$refs.mode.$refs.view.$refs.grid.disableAutoScroll(); + this.currentlyUpdatedEvent = obj.orig; + } + }) + .catch((error) => { + this.currentlyUpdatedEvent = null; + this.$refs.calendar.clearOutCalendarEventEmphasis(); + this.$nextTick(() => { + this.$refs.calendar.reloadEvents(); + }); + throw error; + }); + }, + 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(); + + if (this.raumvorschlagPreview) + this.previewRaumvorschlag({ + lehreinheit_id: this.raumvorschlagPreview.lehreinheit_id, + }); + }, + + 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 || [])); + } + + if (this.raumvorschlagPreview) + res.push(...this.raumvorschlagPreview.slots); + + this.extraBackgrounds = res; + }, + + openReservierung() { + this.reservierungPending = false; + this.$refs.reservierung?.show(); + }, + onEventHover(event) { + this.hoveredEvent = event; + }, + onEventUnhover(event) { + if (this.hoveredEvent?.kalender_id === event?.kalender_id) { + this.hoveredEvent = null; + } + }, + scrollToAndEmphasizeUpdatedEvent() { + if (!this.currentlyUpdatedEvent) return; + + document + .querySelectorAll( + '.fhc-calendar-base-grid .fhc-calendar-base-grid-line-event', + ) + .forEach((el) => { + const spinner = el.querySelector('.spinner-overlay'); + if (spinner) { + spinner.remove(); + } + + el.classList.remove( + 'updating-event', + 'updated-event', + 'updated-event-long', + ); + }); + + setTimeout(() => { + const eventEl = document.querySelector( + `[data-group-id="event-group-${this.currentlyUpdatedEvent.eindeutige_kalender_gruppen_id}"]`, + ); + if (!eventEl) return; + + const calendar = document.querySelector('.fhc-calendar-base-grid'); + const eventRect = eventEl.getBoundingClientRect(); + + const offset = 300; + + const isInsideScrolledView = + eventEl.offsetLeft < calendar.scrollLeft + calendar.clientWidth && + eventEl.offsetLeft + eventEl.offsetWidth > calendar.scrollLeft && + eventEl.offsetTop < calendar.scrollTop + calendar.clientHeight && + eventEl.offsetTop + eventEl.offsetHeight > calendar.scrollTop; + + const rect = eventEl.getBoundingClientRect(); + if (!isInsideScrolledView) { + eventEl.scrollIntoView({ + behavior: 'smooth', + inline: 'center', + block: 'nearest', + }); + } + + let timeout = 0; + let emphasizeUpdateClassName = isInsideScrolledView + ? 'updated-event' + : 'updated-event-long'; + + if (!isInsideScrolledView) timeout = 300; + + setTimeout(() => { + eventEl.classList.add(emphasizeUpdateClassName); + }, timeout); + + this.currentlyUpdatedEvent = null; + }, 100); + }, + updateKalenderEventElementDisplay(calendarGruppenId, startDT, endDT) { + if (!calendarGruppenId) + return; + + let startOfDay = startDT.startOf('day'); + let newPotentialStart = startDT.diff(startOfDay).toMillis() ?? 1; + let newPotentialEnd = endDT.diff(startOfDay).toMillis(); + + const calendar = this.$refs.calendar?.$el; + let element = calendar?.querySelector( + `[data-group-id="event-group-${calendarGruppenId}"]`, + ); + if (!element) return; + + const targetGridLine = [...calendar.querySelectorAll( + '.fhc-calendar-base-grid-line', + )].find((gridLine) => { + const [rowStart, columnStart, rowEnd] = getComputedStyle( + gridLine, + ).gridArea.split(' / '); + + return ( + rowStart === '1' && + columnStart === String(startDT.weekday) && + rowEnd === '-1' + ); + }); + const changedDay = + targetGridLine && element.parentElement !== targetGridLine; + + setTimeout(() => { + if (!calendar.contains(element)) return; + + + if (changedDay) { + targetGridLine.insertBefore(element, null); + element.classList.add('tempus-temporary-calendar-event'); + } + element.style.gridRowEnd = 't_' + newPotentialEnd; + element.style.gridRowStart = 't_' + newPotentialStart; + }, 100); + + const outerDiv = document.createElement('div'); + outerDiv.className = 'spinner-overlay'; + + const innerDiv = document.createElement('div'); + innerDiv.className = 'spinner'; + + outerDiv.appendChild(innerDiv); + + element.appendChild(outerDiv); + + const eventRect = element.getBoundingClientRect(); + + const offset = 300; + + const isInsideScrolledView = + element.offsetLeft < calendar.scrollLeft + calendar.clientWidth && + element.offsetLeft + element.offsetWidth > calendar.scrollLeft && + element.offsetTop < calendar.scrollTop + calendar.clientHeight && + element.offsetTop + element.offsetHeight > calendar.scrollTop; + + const rect = element.getBoundingClientRect(); + if (!isInsideScrolledView) { + element.scrollIntoView({ + behavior: 'smooth', + inline: 'center', + block: 'nearest', + }); + } + }, + clearTemporaryEvents() { + const calendar = this.$refs.calendar?.$el; + if (!calendar) return; + + const tempEvents = calendar.querySelectorAll( + '.tempus-temporary-calendar-event', + ); + tempEvents.forEach((event) => { + event.remove(); + }); + } + }, + watch: { + '$route.query': { + immediate: true, + handler(query) { + this.urlRange = getRangeFromQuery(query, this.config.timezone); + const rangeStart = this.urlRange?.start.toISODate() + ?? luxon.DateTime.now().setZone(this.config.timezone).toISODate(); + + this.calendarDatesByMode = { + ...this.calendarDatesByMode, + range: rangeStart, + }; + }, + }, + lecturers: { + deep: true, + handler() { + this.rebuildExtraBackgrounds(); + }, + }, + rooms() { + this.$refs.calendar.resetEventLoader(); + }, + }, + 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) => { + console.log(Object.keys(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[rendertype]) { + this.renderers[rendertype] = {}; + } + this.renderers[rendertype].modalTitle = modalTitle; + this.renderers[rendertype].modalContent = modalContent; + this.renderers[rendertype].calendarEvent = calendarEvent; + } + }); + }, + template: /* html */ ` +
+ +
`, +}; diff --git a/public/js/composables/Tempus/TempusEventLoader.js b/public/js/composables/Tempus/TempusEventLoader.js index 79fca1cb0..6d33e70fc 100644 --- a/public/js/composables/Tempus/TempusEventLoader.js +++ b/public/js/composables/Tempus/TempusEventLoader.js @@ -1,7 +1,10 @@ +const PLAN_REQUEST_INTERVAL_DAYS = 60; + export function useEventLoader( rangeInterval, getPromiseFunc, cacheMultiplier = 1, + requestIntervalDays = PLAN_REQUEST_INTERVAL_DAYS, ) { let hasFirstLoadOccurred = false; @@ -24,6 +27,14 @@ export function useEventLoader( return Math.round(currentlyDisplayedDateRange.length() * cacheSize) + 1; }; + const getRequestIntervalDays = () => { + const intervalDays = Number(Vue.toValue(requestIntervalDays)); + + return Number.isFinite(intervalDays) && intervalDays > 0 + ? Math.max(1, Math.floor(intervalDays)) + : PLAN_REQUEST_INTERVAL_DAYS; + }; + const reload = (isCacheEnabled = true) => { if ( currentlyDisplayedDateRange && @@ -74,56 +85,66 @@ export function useEventLoader( addVisualForEventsLoading(); - Promise.allSettled(promises).then((results) => { - let newlyLoadedEvents = []; - let hasSuccessfullyLoadedEvents = false; + let newlyLoadedEvents = []; + let pendingPromiseCount = promises.length; + let hasSuccessfullyLoadedEvents = false; - results.forEach((res) => { - if ( - !(res.status === "fulfilled" && res.value.meta.status === "success") - ) - return; + promises.forEach((promise) => { + Promise.resolve(promise) + .then((res) => { + if (res.meta.status !== "success") return; - hasSuccessfullyLoadedEvents = true; - if (res.value.meta.lv) lv.value = res.value.meta.lv; - newlyLoadedEvents = res.value.data; - }); + hasSuccessfullyLoadedEvents = true; + if (res.meta.lv) lv.value = res.meta.lv; + newlyLoadedEvents = newlyLoadedEvents.concat(res.data); - let tempAllEvents = Array.from( - new Map( - [...allEvents.value, ...newlyLoadedEvents].map((event) => [ - event.eindeutige_kalender_gruppen_id, - event, - ]), - ).values(), - ); - - if (!isCacheEnabled && hasSuccessfullyLoadedEvents) { - const newlyLoadedEventIds = new Set( - newlyLoadedEvents.map( - (event) => event.eindeutige_kalender_gruppen_id, - ), - ); - - tempAllEvents = tempAllEvents.filter((event) => { - const eventStart = getTimestampFromISODate(event.isostart); - const eventEnd = getTimestampFromISODate(event.isoend); - const overlapsDisplayedDateRange = - eventStart < currentlyDisplayedDateRange.end.ts && - eventEnd > currentlyDisplayedDateRange.start.ts; - - return ( - !overlapsDisplayedDateRange || - newlyLoadedEventIds.has(event.eindeutige_kalender_gruppen_id) + let tempAllEvents = Array.from( + new Map( + [...allEvents.value, ...newlyLoadedEvents].map((event) => [ + event.eindeutige_kalender_gruppen_id, + event, + ]), + ).values(), ); + + if (!isCacheEnabled) { + const newlyLoadedEventIds = new Set( + newlyLoadedEvents.map( + (event) => event.eindeutige_kalender_gruppen_id, + ), + ); + + tempAllEvents = tempAllEvents.filter((event) => { + const eventStart = getTimestampFromISODate(event.isostart); + const eventEnd = getTimestampFromISODate(event.isoend); + const overlapsDisplayedDateRange = + eventStart < currentlyDisplayedDateRange.end.ts && + eventEnd > currentlyDisplayedDateRange.start.ts; + + return ( + !overlapsDisplayedDateRange || + newlyLoadedEventIds.has(event.eindeutige_kalender_gruppen_id) + ); + }); + } + + allEvents.value = removeVisualForEventsLoading( + ensureEventsAreInValidCacheRange(tempAllEvents), + ); + }) + .catch(() => {}) + .finally(() => { + pendingPromiseCount -= 1; + + if (pendingPromiseCount === 0) { + if (!hasSuccessfullyLoadedEvents) { + allEvents.value = removeVisualForEventsLoading( + ensureEventsAreInValidCacheRange(allEvents.value), + ); + } + hasFirstLoadOccurred = true; + } }); - } - - allEvents.value = removeVisualForEventsLoading( - ensureEventsAreInValidCacheRange(tempAllEvents), - ); - - hasFirstLoadOccurred = true; }); }; @@ -188,6 +209,32 @@ export function useEventLoader( allowedCacheStartTimestamp = startTimestamp - cachePadding; allowedCacheEndTimestamp = endTimestamp + cachePadding; + const requestEnd = getLuxonDateFromMillis(modifiedRequestEndTimestamp); + let requestStart = getLuxonDateFromMillis(modifiedRequestStartTimestamp); + const intervalDays = getRequestIntervalDays(); + const intervalEnd = requestStart + .plus({ days: intervalDays - 1 }) + .endOf("day"); + + if (requestEnd > intervalEnd) { + while (requestStart < requestEnd) { + console.log("requestStart", requestStart.toISO() + " requestEnd", requestEnd.toISO() + " intervalDays", intervalDays); + let intervalRequestEnd = requestStart + .plus({ days: intervalDays - 1 }) + .endOf("day"); + if (intervalRequestEnd > requestEnd) intervalRequestEnd = requestEnd; + + result = mergePromiseElements( + getPromiseFunc(requestStart, intervalRequestEnd), + result, + ); + + requestStart = intervalRequestEnd.plus({ milliseconds: 1 }); + } + + return result; + } + return mergePromiseElements( getPromiseFunc( getLuxonDateFromMillis(modifiedRequestStartTimestamp),