diff --git a/application/controllers/api/frontend/v1/tempus/Kalender.php b/application/controllers/api/frontend/v1/tempus/Kalender.php index 5ba48d13e..ccf6fdf75 100644 --- a/application/controllers/api/frontend/v1/tempus/Kalender.php +++ b/application/controllers/api/frontend/v1/tempus/Kalender.php @@ -104,6 +104,9 @@ class Kalender extends FHCAPI_Controller $start_date = $this->_ci->input->post('start_date', TRUE); $end_date = $this->_ci->input->post('end_date', TRUE); + $isCollisionCheckEnabled = $this->_ci->input->post('collisionCheck', TRUE) ?? false; + $maxDailyEventLimit = $this->_ci->input->post('maxDailyEventLimit', TRUE) ?? false; + $filter = $this->_checkFilter(self::ALLOWED_PLAN_FILTER); $stundenplan_data = $this->_ci->kalenderlib->getPlanForPlanner( @@ -111,7 +114,9 @@ class Kalender extends FHCAPI_Controller $end_date, isset($filter->ort) ? $filter->ort : null, isset($filter->uid) ? $filter->uid : null, - isset($filter->stg) ? $filter->stg : null + isset($filter->stg) ? $filter->stg : null, + $isCollisionCheckEnabled, + $maxDailyEventLimit ); $this->terminateWithSuccess($stundenplan_data); diff --git a/application/libraries/KalenderLib.php b/application/libraries/KalenderLib.php index 2271b97e6..f0259338c 100644 --- a/application/libraries/KalenderLib.php +++ b/application/libraries/KalenderLib.php @@ -243,7 +243,7 @@ class KalenderLib return $this->_ci->KalenderModel->execReadOnlyQuery($query); } - private function _mapEvents($data, $collisionCheck = true) + private function _mapEvents($data, $collisionCheck = true, $maxDailyEventLimit = null) { $stundenplan_data = []; @@ -264,6 +264,7 @@ class KalenderLib $parsedResources = is_string($resources) ? json_decode($resources, true) : $resources; $events[$id] = (object) [ + 'kalender_id' => $id, 'eindeutige_kalender_gruppen_id' => $row->eindeutige_kalender_gruppen_id, 'type' => $row->typ, 'beginn' => $von->format('H:i:s'), @@ -282,7 +283,6 @@ class KalenderLib 'lehrveranstaltung_id' => $row->lehrveranstaltung_id, 'verplante_stunden' => $row->verplante_stunden, 'organisationseinheit' => isset($row->oe_kurzbz) ? $row->oe_kurzbz : '', - 'kalender_id' => $id, 'lehreinheit_id' => [], 'lektor' => [], 'teilnehmer_gruppe' => [], @@ -386,6 +386,21 @@ class KalenderLib } } + if (!is_null($maxDailyEventLimit) && is_numeric($maxDailyEventLimit) && $maxDailyEventLimit > 0) + { + $eventsPerDay = []; + $events = array_filter($events, function ($event) use (&$eventsPerDay, $maxDailyEventLimit) { + $date = $event->datum; + $eventsPerDay[$date] = $eventsPerDay[$date] ?? 0; + + if ($eventsPerDay[$date] >= $maxDailyEventLimit) + return false; + + $eventsPerDay[$date]++; + return true; + }); + } + return array_values($events); } public function getPlanByOrt($start_date, $end_date, $ort) @@ -456,7 +471,7 @@ class KalenderLib $data = $this->_loadFilteredBasePlan($kalender_entry->von, $kalender_entry->bis); return $this->_mapEvents($data); } - public function getPlanForPlanner($start_date, $end_date, $ort = null, $uids = null, $studiengaenge = null) + public function getPlanForPlanner($start_date, $end_date, $ort = null, $uids = null, $studiengaenge = null, $collisionCheck = true, $maxDailyEventLimit = null) { $this->_buildBasePlanQuery(); @@ -563,7 +578,7 @@ class KalenderLib $data = $this->_loadFilteredBasePlan($start_date, $end_date); - return $this->_mapEvents($data); + return $this->_mapEvents($data, $collisionCheck, $maxDailyEventLimit); } public function getPlanForStudent($start_date, $end_date) diff --git a/public/css/Tempus.css b/public/css/Tempus.css index 6ca71025b..fee093202 100644 --- a/public/css/Tempus.css +++ b/public/css/Tempus.css @@ -6,19 +6,19 @@ @import './components/Notiz.css'; html { - font-size: .875em; - height: 100%; + font-size: 0.875em; + height: 100%; } body { /*display: flex;*/ - height: 100%; + height: 100%; } .heightfull { - height: 95%; + height: 95%; } .navbar-dark .navbar-brand:focus { - box-shadow: 0 0 0 .25rem rgba(13,110,253,.25); + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); z-index: 3; } #main { @@ -28,13 +28,13 @@ body { height: 100%; } .searchbar { - margin-right: 0!important; + margin-right: 0 !important; } .searchbar > .input-group { - margin-right: 0!important; + margin-right: 0 !important; } .searchbar > .input-group > * { - border-radius: 0!important; + border-radius: 0 !important; } #sidebarMenu { @@ -55,7 +55,7 @@ body { @media (min-width: 768px) { #sidebarMenu { - visibility: visible!important; + visibility: visible !important; transform: none; position: inherit; z-index: 1; @@ -64,27 +64,24 @@ body { .toast.toast-success { color: #0f5132; - background-color: #d1e7dd!important; - border-color: #badbcc!important; + background-color: #d1e7dd !important; + border-color: #badbcc !important; } .toast.toast-danger { color: #842029; - background-color: #f8d7da!important; - border-color: #f5c2c7!important; + background-color: #f8d7da !important; + border-color: #f5c2c7 !important; } .has-filter .fa-filter { color: var(--bs-success); } - - - #parkingslot { border: 1px dashed; min-height: 5vh; max-height: 15vh; - color: #AAAAAA; + color: #aaaaaa; text-align: center; font-size: large; margin-top: 1.25rem; @@ -96,7 +93,7 @@ body { } .course-picker { - border: 1px dashed #AAAAAA; + border: 1px dashed #aaaaaa; display: flex; flex-direction: column; flex: 1 1 0; @@ -132,11 +129,11 @@ body { } .eckerltest { - box-shadow: 3px 3px 3px #ccc; + box-shadow: 3px 3px 3px #ccc; } .verband-selection { - border: 1px dashed #AAAAAA; + border: 1px dashed #aaaaaa; margin-bottom: 0.5rem; max-height: 450px; overflow-y: auto; @@ -145,7 +142,7 @@ body { } .lecture-selection { - border: 1px dashed #AAAAAA; + border: 1px dashed #aaaaaa; margin-bottom: 0.5rem; max-height: 20vh; overflow-y: auto; @@ -154,7 +151,7 @@ body { } .room-selection { - border: 1px dashed #AAAAAA; + border: 1px dashed #aaaaaa; margin-bottom: 0.5rem; padding: 0.5rem 0 0.5rem 0.5rem; } @@ -169,10 +166,8 @@ body { transform: scale(1.1); } - - .bg-lecturer-wish { - opacity: .15; + opacity: 0.15; } .bg-lecturer-block { background: rgba(255, 0, 0, 0.15); @@ -195,18 +190,21 @@ body { .wish-w--2 { - background-color: #FF2200; + background-color: #ff2200; } .wish-w--1 { - background-color: #FF9922; + background-color: #ff9922; } .wish-w-1 { - background-color: #CCFFCC; + background-color: #ccffcc; } .wish-w-2 { - background-color: #48FA66; + background-color: #48fa66; } +.fhc-calendar-base-grid-line-event { + position: relative !important; +} .room-option { display: grid; @@ -214,3 +212,74 @@ body { align-items: center; width: 100%; } + +.updated-event { + position: relative !important; + z-index: 10; + animation: modernFocus 1s ease-out; + border-radius: 0.25rem; +} + +.updated-event-long { + position: relative !important; + z-index: 10; + animation: modernFocus 1.8s ease-out; + border-radius: 0.25rem; +} + +@keyframes modernFocus { + 0% { + background-color: #cfd4d8; + box-shadow: + 0 0 0 0 rgba(120, 120, 120, 0.8), + 0 0 0 0 rgba(255, 255, 255, 0.6); + } + + 30% { + background-color: #eef1f3; + box-shadow: + 0 0 0 8px rgba(120, 120, 120, 0.25), + 0 0 30px 8px rgba(255, 255, 255, 0.7); + } + + 60% { + background-color: #f7f8f9; + box-shadow: + 0 0 0 14px rgba(120, 120, 120, 0.15), + 0 0 45px 12px rgba(255, 255, 255, 0.5); + } + + 100% { + background-color: inherit; + box-shadow: + 0 0 0 24px rgba(120, 120, 120, 0), + 0 0 60px 20px rgba(255, 255, 255, 0); + } +} + +.spinner-overlay { + position: absolute; + inset: 0; /* top:0 right:0 bottom:0 left:0 */ + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + background: rgba(255, 255, 255, 1); + opacity: 0.4; +} + +.spinner { + width: 40px; + height: 40px; + border: 4px solid #ddd; + border-top: 4px solid #007bff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } + +} \ No newline at end of file diff --git a/public/js/api/factory/tempus/kalender.js b/public/js/api/factory/tempus/kalender.js index c12bedd2f..046aec385 100644 --- a/public/js/api/factory/tempus/kalender.js +++ b/public/js/api/factory/tempus/kalender.js @@ -1,11 +1,17 @@ export default { - getPlan(filter, start_date, end_date) + getPlan(filter, start_date, end_date, collisionCheck = true, maxDailyEventLimit = null) { return { method: 'post', url: '/api/frontend/v1/tempus/Kalender/getPlan', - params: { ...filter, start_date, end_date } + params: { + ...filter, + start_date, + end_date, + collisionCheck, + ...(maxDailyEventLimit == null ? {} : { maxDailyEventLimit }) + } }; }, getRaumvorschlagSlots(lehreinheit_id, start_date, end_date) @@ -21,15 +27,25 @@ export default { return { method: 'get', url: '/api/frontend/v1/tempus/Kalender/getPlanLecturer', - params: { start_date, end_date } + params: { + start_date, + end_date, + collisionCheck, + ...(maxDailyEventLimit == null ? {} : { maxDailyEventLimit }) + } }; }, - getPlanStudent(start_date, end_date) + getPlanStudent(start_date, end_date, collisionCheck = true, maxDailyEventLimit = null) { return { method: 'get', url: '/api/frontend/v1/tempus/Kalender/getPlanStudent', - params: { start_date, end_date } + params: { + start_date, + end_date, + collisionCheck, + ...(maxDailyEventLimit == null ? {} : { maxDailyEventLimit }) + } }; }, diff --git a/public/js/components/Calendar/Base.js b/public/js/components/Calendar/Base.js index 6772028ec..06f4c56c7 100644 --- a/public/js/components/Calendar/Base.js +++ b/public/js/components/Calendar/Base.js @@ -125,6 +125,10 @@ export default { draggableEvents: [Boolean, Array, Function], dropableEvents: [Boolean, Array, Function], resizableEvents: [Boolean, Array, Function], + eventReloadKey: { + type: Number, + default: 0, + }, onDragover: Function, onDrop: Function, onResize: Function @@ -333,6 +337,7 @@ export default { { event.rows = [1, -1]; if (event.startsHere) { - event.rows[0] = - "t_" + event.start.diff(this.date).toMillis(); + event.rows[0] = 't_' + event.start.diff(this.date).toMillis(); } if (event.endsHere) { - event.rows[1] = "t_" + event.end.diff(this.date).toMillis(); + event.rows[1] = 't_' + event.end.diff(this.date).toMillis(); } return event; }); if (this.shouldCompactEvents && this.compactibleEventTypes?.length) { - formattedEvents = - this.compactEvents(formattedEvents, this.compactibleEventTypes); + formattedEvents = this.compactEvents( + formattedEvents, + this.compactibleEventTypes, + ); } return formattedEvents; @@ -81,12 +82,9 @@ export default { methods: { compactEvents(events, compactibleEventTypes) { let formattedEvents = events - .filter( - (event) => - !compactibleEventTypes.includes(event.type), - ) + .filter((event) => !compactibleEventTypes.includes(event.type)) .map((event) => { - event.display = "default"; + event.display = 'default'; return event; }); let eventsToBeCompacted = events.filter((event) => @@ -120,17 +118,17 @@ export default { compactedEvents.forEach((compactedEvent) => { if (compactedEvent.events.length < 4) { formattedEvents.push({ - display: "compacted", + display: 'compacted', ...compactedEvent, }); } else { formattedEvents.push({ - display: "compacted", + display: 'compacted', events: compactedEvent.events.slice(0, 3), rows: compactedEvent.rows, }); formattedEvents.push({ - display: "compactedExtra", + display: 'compactedExtra', events: compactedEvent.events.slice(3), rows: compactedEvent.rows, }); diff --git a/public/js/components/Calendar/Base/Grid/Line/Event.js b/public/js/components/Calendar/Base/Grid/Line/Event.js index 1bd97ef8c..4efa47d55 100644 --- a/public/js/components/Calendar/Base/Grid/Line/Event.js +++ b/public/js/components/Calendar/Base/Grid/Line/Event.js @@ -3,72 +3,74 @@ import drop from '../../../../../directives/drop.js'; import CalClick from '../../../../../directives/Calendar/Click.js'; export default { - name: "GridLineEvent", + name: 'GridLineEvent', directives: { draggable, drop, - CalClick + CalClick, }, - emits: [ - 'resize-start' - ], + emits: ['resize-start'], data() { return { contextMenu: { show: false, x: 0, - y: 0 - } + y: 0, + }, }; }, inject: { - draggableEvents: "draggableEvents", + draggableEvents: 'draggableEvents', resizableEvents: { - from: "resizableEvents", - default: () => () => false + from: 'resizableEvents', + default: () => () => false, }, - mode: "mode", + mode: 'mode', contextMenuActions: { - from: "contextMenuActions", - default: () => ({}) + from: 'contextMenuActions', + default: () => ({}), }, onDrop: { - from: "onDrop", - default: () => null + from: 'onDrop', + default: () => null, }, onDropEvent: { - from: "onDropEvent", - default: () => () => {} - } + from: 'onDropEvent', + default: () => () => {}, + }, }, props: { event: { type: Object, required: true, validator(value) { - return (value.start && value.end && value.orig); - } - } + 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); + return ( + !this.isHeaderOrFooter && + this.draggableEvents(this.event.orig, this.mode) + ); }, resizable() { - return !this.isHeaderOrFooter && this.resizableEvents(this.event.orig, this.mode); + return ( + !this.isHeaderOrFooter && + this.resizableEvents(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'); + if (this.event.startsHere) classes.push('event-begin'); + if (this.event.endsHere) classes.push('event-end'); } classes.push(`calender_id-${this.event.orig.kalender_id}`); @@ -85,9 +87,14 @@ export default { activeContextActions() { if (this.isHeaderOrFooter) return []; const type = this.event.orig?.type ?? 'lehreinheit'; - const actions = this.contextMenuActions[type] ?? this.contextMenuActions['default'] ?? []; - return actions.filter(action => !action.visible || action.visible(this.event.orig)); - } + const actions = + this.contextMenuActions[type] ?? + this.contextMenuActions['default'] ?? + []; + return actions.filter( + (action) => !action.visible || action.visible(this.event.orig), + ); + }, }, methods: { onResizeStart(edge, evt) { @@ -96,7 +103,7 @@ export default { evt, horizontal: this.mode === 'range', el: this.$refs.eventEl, - event: this.event + event: this.event, }); }, onRightClick(evt) { @@ -117,27 +124,24 @@ export default { evt.dataTransfer.setData('fhc-grab-offset-x', evt.clientX - rect.left); }, onDropOnCard(evt, items) { - if (this.isHeaderOrFooter || !this.onDrop) - return; + if (this.isHeaderOrFooter || !this.onDrop) return; const list = Array.isArray(items) ? items : [items]; const obj = list[0]; - if (!obj) - return; + if (!obj) return; - if ((evt.ctrlKey || evt.metaKey) && obj.type === 'lehreinheit') - { + if ((evt.ctrlKey || evt.metaKey) && obj.type === 'lehreinheit') { return this.onDrop({ item: [obj], ctrlKey: true, - targetKalenderId: this.event.orig?.kalender_id ?? null + targetKalenderId: this.event.orig?.kalender_id ?? null, }); } return this.onDropEvent(evt, items, this.event.start.startOf('day')); }, }, - template:` + template: `
- ` -} + `, +}; diff --git a/public/js/components/Calendar/Base/Slider.js b/public/js/components/Calendar/Base/Slider.js index b07506c53..aef603ac5 100644 --- a/public/js/components/Calendar/Base/Slider.js +++ b/public/js/components/Calendar/Base/Slider.js @@ -76,15 +76,15 @@ export default { if (!this.onVisibleDatesChanged || typeof IntersectionObserver === 'undefined') return; - if ( - this.$el.closest('.fhc-calendar-mode-range') - && typeof ResizeObserver !== 'undefined' - ) - { - this.gridLineResizeObserver = Vue.markRaw(new ResizeObserver(entries => { - entries.forEach(entry => this.preserveGridLineHeight(entry.target)); - })); - } + // if ( + // this.$el.closest('.fhc-calendar-mode-range') + // && typeof ResizeObserver !== 'undefined' + // ) + // { + // this.gridLineResizeObserver = Vue.markRaw(new ResizeObserver(entries => { + // entries.forEach(entry => this.preserveGridLineHeight(entry.target)); + // })); + // } this.intersectionObserver = Vue.markRaw(new IntersectionObserver(entries => { let changed = false; @@ -112,8 +112,8 @@ export default { mutations.forEach(mutation => { if (mutation.type === 'attributes') { - this.applyPreservedGridLineHeight(mutation.target); - this.gridLineResizeObserver?.observe(mutation.target); + // this.applyPreservedGridLineHeight(mutation.target); + // this.gridLineResizeObserver?.observe(mutation.target); if (this.visibleGridLines.has(mutation.target)) changed = true; return; @@ -123,7 +123,7 @@ export default { mutation.removedNodes.forEach(node => { this.getGridLines(node).forEach(line => { this.intersectionObserver.unobserve(line); - this.gridLineResizeObserver?.unobserve(line); + // this.gridLineResizeObserver?.unobserve(line); if (this.visibleGridLines.delete(line)) changed = true; }); @@ -157,12 +157,14 @@ export default { }); }, getGridLineKey(line) { + return; const start = line.dataset.visibleStart; const end = line.dataset.visibleEnd; return start && end ? start + '/' + end : null; }, applyPreservedGridLineHeight(line) { + return; if (!this.gridLineResizeObserver) return; @@ -171,6 +173,7 @@ export default { line.style.minHeight = height ? height + 'px' : ''; }, preserveGridLineHeight(line) { + return; const key = this.getGridLineKey(line); if (!key) return; diff --git a/public/js/components/Calendar/Mode/Month.js b/public/js/components/Calendar/Mode/Month.js index a352ccc98..2bb0d4424 100644 --- a/public/js/components/Calendar/Mode/Month.js +++ b/public/js/components/Calendar/Mode/Month.js @@ -27,18 +27,9 @@ export default { }, 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'); - } - } + const targetDate = this.focusDate.plus({ months: this.rangeOffset }); + const first = targetDate.startOf('month').startOf('week', { useLocaleWeeks: true }); + const last = first.plus({ days: 41 }).endOf('day'); // NOTE(chris): 6 weeks minus 1 day return luxon.Interval.fromDateTimes(first, last); } diff --git a/public/js/components/Calendar/Mode/Week.js b/public/js/components/Calendar/Mode/Week.js index f8653b9e9..c6f776f73 100644 --- a/public/js/components/Calendar/Mode/Week.js +++ b/public/js/components/Calendar/Mode/Week.js @@ -27,16 +27,9 @@ export default { }, 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 }); - } - } + const targetDate = this.focusDate.plus({ weeks: this.rangeOffset }); + const first = targetDate.startOf('week', { useLocaleWeeks: true }); + const last = targetDate.endOf('week', { useLocaleWeeks: true }); return luxon.Interval.fromDateTimes(first, last); } @@ -98,7 +91,7 @@ export default { this.$emit('update:range', this.range); this.$refs.view.$refs.grid.enableAutoScroll(); }, - template: ` + template: /* html */`
new Set() + default: () => new Set(), }, visibleLecturers: { type: Array, - default: null + default: null, }, extraBackgrounds: { type: Array, - default: () => [] + default: () => [], }, visibleStatus: { type: Array, - default: () => ['all'] + default: () => ['all'], }, showEvents: { type: Boolean, @@ -99,11 +103,14 @@ export default { "resize", "event-hover", "event-unhover", - "open-reservierung" + "open-reservierung", + "events-reloaded", ], data() { return { visibleDates: null, + eventReloadKey: 0, + refreshEventsAfterReload: false, modes: { week: Vue.markRaw(ModeWeek), month: Vue.markRaw(ModeMonth), @@ -113,11 +120,11 @@ export default { modeOptions: { day: { emptyMessage: Vue.computed(() => this.$p.t('lehre/noLvFound')), - emptyMessageDetails: Vue.computed(() => this.$p.t('lehre/noLvFound')) + emptyMessageDetails: Vue.computed(() => this.$p.t('lehre/noLvFound')), }, week: { - collapseEmptyDays: false - } + collapseEmptyDays: false, + }, }, currentMode: this.mode, teachingunits: null, @@ -131,6 +138,16 @@ export default { }, }; }, + watch: { + events() { + if (this.refreshEventsAfterReload) { + this.eventReloadKey += 1; + this.refreshEventsAfterReload = false; + } + + this.$emit('events-reloaded'); + } + }, computed: { backgrounds() { let now = luxon.DateTime.now().setZone(this.timezone); @@ -154,13 +171,9 @@ export default { }]; } - return [ - ...past, - ...(this.extraBackgrounds || []) - ]; + return [...past, ...(this.extraBackgrounds || [])]; }, - visibleEvents() - { + visibleEvents() { let list = this.events; // Start with the first week and then keep events overlapping a grid line @@ -195,23 +208,25 @@ export default { { const visibleLectures = new Set(this.visibleLecturers); - list = list.filter(event => { - if (!event.lektor?.length) - return true; - return event.lektor.some(lektor => visibleLectures.has(lektor.mitarbeiter_uid)); + list = list.filter((event) => { + if (!event.lektor?.length) return true; + return event.lektor.some((lektor) => + visibleLectures.has(lektor.mitarbeiter_uid), + ); }); } if (!this.visibleStatus.length || this.visibleStatus.includes('all')) return list; - return list.filter(event => this.visibleStatus.includes(event.status_kurzbz)); + return list.filter((event) => + this.visibleStatus.includes(event.status_kurzbz), + ); }, }, methods: { eventStyle(event) { - if (!event.farbe) - return undefined; + if (!event.farbe) return undefined; return '--event-bg:#' + event.farbe; }, updateRange(rangeInterval) { @@ -248,11 +263,15 @@ export default { ondrop(payload){ this.$emit('drop', payload); }, - onresize(payload){ + onresize(payload) { this.$emit('resize', payload); }, - resetEventLoader() { - this.reset(); + resetEventLoader(arePreviousEventsCleared = true) { + this.reset(arePreviousEventsCleared); + }, + reloadEvents() { + this.refreshEventsAfterReload = true; + this.resetEventLoader(false); }, navigatePrev() { this.$refs.calendar.clickPrev(); @@ -278,13 +297,35 @@ export default { }; } }, + clearOutCalendarEventEmphasis() { + this.$refs.calendar.$el + .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', + ); + }); + }, }, setup(props, context) { const rangeInterval = Vue.ref(null); - const { events, lv, reset } = useEventLoader(rangeInterval, props.getPromiseFunc); + const { events, lv, reset } = useEventLoader( + rangeInterval, + props.getPromiseFunc, + () => props.cacheMultiplier + ); - Vue.watch(lv, newValue => { + Vue.watch(lv, (newValue) => { context.emit('update:lv', newValue); }); @@ -292,33 +333,27 @@ export default { rangeInterval, events, lv, - reset + reset, }; }, created() { - this.$api - .call(ApiKalender.getStunden()) - .then(res => { - return this.teachingunits = res.data.map(el => ({ - id: el.stunde, - start: el.beginn, - end: el.ende - })); - }); + this.$api.call(ApiKalender.getStunden()).then((res) => { + return (this.teachingunits = res.data.map((el) => ({ + id: el.stunde, + start: el.beginn, + end: el.ende, + }))); + }); - this.$api - .call(ApiKalender.getCalendarHours()) - .then(res => { - this.hoursplan = { - start: res.data.start, - end: res.data.end - }; - }); - - this.fetchSemesters(); + this.$api.call(ApiKalender.getCalendarHours()).then((res) => { + this.hoursplan = { + start: res.data.start, + end: res.data.end, + }; + }); }, - template: /* html */` + template: /* html */ ` this.deleteEntryCall(orig)), ); - this.$refs.calendar.resetEventLoader(); + this.$refs.calendar.resetEventLoader(false); this.$refs.sidebar.reloadCoursepicker(); }, async deleteEntryCall(orig) { @@ -437,6 +439,9 @@ export default { return this.$api.call(ApiSearchbar.search(params)); }, getPromiseFunc(start, end) { + const isMonth = this.currentMode?.toLowerCase() === "month"; + const collisionCheck = !isMonth; + const maxDailyEventLimit = isMonth ? this.maxDailyEventLimitForMonthView : null; const hasRooms = this.rooms.length > 0; const hasLektoren = this.lecturers.length > 0; const hasStg = this.studiengaenge.length > 0; @@ -456,25 +461,50 @@ export default { if (hasLektoren) filter.uid = this.lecturers.map((lecture) => lecture.uid); + let response = null; if (this.previewRole === 'lektor') - return [ + response = [ this.$api.call( - ApiKalender.getPlanLecturer(start.toISODate(), end.toISODate()), + ApiKalender.getPlanLecturer( + start.toISODate(), + end.toISODate(), + collisionCheck, + maxDailyEventLimit, + ), ), ]; if (this.previewRole === 'student') - return [ + response = [ this.$api.call( - ApiKalender.getPlanStudent(start.toISODate(), end.toISODate()), + ApiKalender.getPlanStudent( + start.toISODate(), + end.toISODate(), + collisionCheck, + maxDailyEventLimit, + ), ), ]; - return [ + response = [ this.$api.call( - ApiKalender.getPlan(filter, start.toISODate(), end.toISODate()), + ApiKalender.getPlan( + filter, + start.toISODate(), + end.toISODate(), + collisionCheck, + maxDailyEventLimit, + ), ), ]; + + if (response) { + response[0].then((result) => { + this.scrollToAndEmphasizeUpdatedEvent(); + }); + } + + return response; }, _parseDates(start, end) { const startDT = luxon.DateTime.fromISO(start); @@ -516,7 +546,19 @@ export default { ApiKalender.updateKalenderEvent(obj.orig.kalender_id, updatedInfos), ) .then(() => { - if (onSuccess) onSuccess(); + 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; }); }, @@ -527,12 +569,26 @@ export default { const { item, start, end } = payload; const obj = item[0]; if (!obj?.orig?.kalender_id) - return alert('Kein gültiges Kalender-Event zum Resizen'); + return; const dates = this._parseDates(start, end); if (!dates) return; + if ( + luxon.DateTime.fromISO(obj.orig.isostart).toMillis() === + dates.startDT.toMillis() && + luxon.DateTime.fromISO(obj.orig.isoend).toMillis() === + dates.endDT.toMillis() + ) + return; + + this.updateKalenderEventElementDisplay( + obj.orig.eindeutige_kalender_gruppen_id, + dates.startDT, + dates.endDT, + ); + return this._updateKalenderEvent( obj, dates.startDT, @@ -540,7 +596,7 @@ export default { dates.start_time, dates.end_time, () => { - this.$refs.calendar.resetEventLoader(); + this.$refs.calendar.resetEventLoader(false); this.$refs.sidebar.reloadCoursepicker(); this.rebuildRaumvorschlag(); }, @@ -571,7 +627,7 @@ export default { ), ) .then(() => { - this.$refs.calendar.resetEventLoader(); + this.$refs.calendar.resetEventLoader(false); this.$refs.sidebar.reloadCoursepicker(); this.rebuildRaumvorschlag(); this.bcc.postMessage('dropped'); @@ -581,8 +637,22 @@ export default { const dates = this._parseDates(start, end); if (!dates) return; + if ( + luxon.DateTime.fromISO(obj.orig.isostart).toMillis() === + dates.startDT.toMillis() && + luxon.DateTime.fromISO(obj.orig.isoend).toMillis() === + dates.endDT.toMillis() + ) + return; + const { startDT, endDT, start_time, end_time } = dates; + this.updateKalenderEventElementDisplay( + obj.orig.eindeutige_kalender_gruppen_id, + startDT, + endDT, + ); + if (obj.type === 'reservierung') { this.reservierungPending = true; this.$refs.reservierung.show(start_time, end_time); @@ -628,7 +698,7 @@ export default { return; } - this.$refs.calendar.resetEventLoader(); + this.$refs.calendar.resetEventLoader(false); this.$refs.sidebar.reloadCoursepicker(); this.rebuildRaumvorschlag(); this.bcc.postMessage('dropped'); @@ -646,7 +716,7 @@ export default { ), ) .then(() => { - this.$refs.calendar.resetEventLoader(); + this.$refs.calendar.resetEventLoader(false); this.$refs.sidebar.reloadCoursepicker(); this.bcc.postMessage('dropped'); this.rebuildRaumvorschlag(); @@ -671,7 +741,7 @@ export default { type: obj.type, id: obj.orig.kalender_id, }); - this.$refs.calendar.resetEventLoader(); + this.$refs.calendar.resetEventLoader(false); this.rebuildRaumvorschlag(); this.bcc.postMessage('dropped'); }, @@ -691,7 +761,7 @@ export default { this.multiWeekModal.show = false; }, onMultiWeekConfirmed() { - this.$refs.calendar.resetEventLoader(); + this.$refs.calendar.resetEventLoader(false); this.$refs.sidebar.reloadCoursepicker(); this.rebuildRaumvorschlag(); this.bcc.postMessage('dropped'); @@ -872,6 +942,148 @@ export default { focusSearchbar() { this.$refs.header?.focusSearchbar(); }, + 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: { lecturers: { @@ -1021,7 +1233,9 @@ export default { @open-reservierung="openReservierung" :extra-backgrounds="extraBackgrounds" @update:range="handleRange" + @events-reloaded="clearTemporaryEvents" class="responsive-calendar" + :cache-multiplier="currentMode === 'week' ? 1 : 0" /> @@ -1032,7 +1246,6 @@ export default { ref="verbandMenu" @select-verband-and-close="onSelectVerbandAndClose" /> - { + const cacheSize = Math.max(Vue.toValue(cacheMultiplier), 0); + + return Math.round(currentlyDisplayedDateRange.length() * cacheSize) + 1; + }; + + const reload = (isCacheEnabled = true) => { + if ( + currentlyDisplayedDateRange && + currentlyDisplayedDateRange?.start?.ts === + rangeInterval?.value?.start?.ts && + currentlyDisplayedDateRange?.end?.ts === rangeInterval?.value?.end?.ts && + isCacheEnabled + ) { + return; + } + + currentlyDisplayedDateRange = Vue.toValue(rangeInterval); + + if ( + !currentlyDisplayedDateRange?.start || + !currentlyDisplayedDateRange?.end + ) + return; + + if (!(currentlyDisplayedDateRange instanceof luxon.Interval)) return; + + if ( + cachedEventsStartTimestamp === currentlyDisplayedDateRange.start.ts && + cachedEventsEndTimestamp === currentlyDisplayedDateRange.end.ts && + isCacheEnabled + ) { + return; + } + + const promises = requestEvents( + currentlyDisplayedDateRange.start.ts, + currentlyDisplayedDateRange.end.ts, + isCacheEnabled + ); + + ensureCacheRangeIsInAllowedRange( + allowedCacheStartTimestamp, + allowedCacheEndTimestamp, + ); + + if (promises.length === 0) { + allEvents.value = removeVisualForEventsLoading( + ensureEventsAreInValidCacheRange(allEvents.value), + ); + + return; + } + + addVisualForEventsLoading(); + + Promise.allSettled(promises).then((results) => { + let newlyLoadedEvents = []; + let hasSuccessfullyLoadedEvents = false; + + results.forEach((res) => { + if ( + !(res.status === "fulfilled" && res.value.meta.status === "success") + ) + return; + + hasSuccessfullyLoadedEvents = true; + if (res.value.meta.lv) lv.value = res.value.meta.lv; + newlyLoadedEvents = res.value.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) + ); + }); + } + + allEvents.value = removeVisualForEventsLoading( + ensureEventsAreInValidCacheRange(tempAllEvents), + ); + + hasFirstLoadOccurred = true; + }); + }; + + Vue.watchEffect(reload); + + const reset = (arePreviousEventsCleared = true) => { + if (arePreviousEventsCleared) { + allEvents.value = []; + hasFirstLoadOccurred = false; + } + reload(false); + }; + + const requestEvents = (startTimestamp, endTimestamp, isCacheEnabled = true) => { + let result = []; + + if (!startTimestamp || !endTimestamp) return result; + if (startTimestamp >= endTimestamp) return result; + + let modifiedRequestStartTimestamp = startTimestamp; + let modifiedRequestEndTimestamp = endTimestamp; + + if (cachedEventsStartTimestamp && cachedEventsEndTimestamp && isCacheEnabled) { + if ( + ( + (startTimestamp < cachedEventsStartTimestamp && + endTimestamp > cachedEventsEndTimestamp) || + (startTimestamp < cachedEventsStartTimestamp && + endTimestamp < cachedEventsEndTimestamp && Math.abs(getLuxonDateFromMillis(endTimestamp).diff(getLuxonDateFromMillis(cachedEventsStartTimestamp), 'days')) > 1) || + (startTimestamp > cachedEventsStartTimestamp && + endTimestamp > cachedEventsEndTimestamp && Math.abs(getLuxonDateFromMillis(startTimestamp).diff(getLuxonDateFromMillis(cachedEventsEndTimestamp), 'days')) > 1) + ) + ) { + modifiedRequestStartTimestamp = startTimestamp; + modifiedRequestEndTimestamp = endTimestamp; + cachedEventsStartTimestamp = startTimestamp; + cachedEventsEndTimestamp = endTimestamp; + } else if ( + startTimestamp < cachedEventsStartTimestamp && + endTimestamp <= cachedEventsEndTimestamp + ) { + modifiedRequestStartTimestamp = startTimestamp; + modifiedRequestEndTimestamp = cachedEventsStartTimestamp; + cachedEventsStartTimestamp = startTimestamp; + } else if ( + startTimestamp >= cachedEventsStartTimestamp && + endTimestamp > cachedEventsEndTimestamp + ) { + modifiedRequestStartTimestamp = cachedEventsEndTimestamp; + modifiedRequestEndTimestamp = endTimestamp; + cachedEventsEndTimestamp = endTimestamp; + } else { + return result; + } + } else if (isCacheEnabled) { + cachedEventsStartTimestamp = modifiedRequestStartTimestamp; + cachedEventsEndTimestamp = modifiedRequestEndTimestamp; + } + + const cachePadding = getCachePadding(); + + allowedCacheStartTimestamp = startTimestamp - cachePadding; + allowedCacheEndTimestamp = endTimestamp + cachePadding; + + return mergePromiseElements( + getPromiseFunc( + getLuxonDateFromMillis(modifiedRequestStartTimestamp), + getLuxonDateFromMillis(modifiedRequestEndTimestamp), + ), + result, + ); + }; + + const addVisualForEventsLoading = (startTimestamp, endTimestamp) => { + if (hasFirstLoadOccurred) return; + + allEvents.value.push({ + loading_id: 1, + type: "loading", + isostart: getISODateFromTimestamp(currentlyDisplayedDateRange.start.ts), + isoend: getISODateFromTimestamp(currentlyDisplayedDateRange.end.ts), + }); + }; + + const removeVisualForEventsLoading = (events) => { + return events.filter((event) => event.type !== "loading"); + }; + + const ensureEventsAreInValidCacheRange = (events) => { + events = events.filter((event) => { + const start = getTimestampFromISODate(event.isostart); + const end = getTimestampFromISODate(event.isoend); + return ( + allowedCacheStartTimestamp <= start && allowedCacheEndTimestamp >= end + ); + }); + + return events; + }; + + const ensureCacheRangeIsInAllowedRange = ( + allowedCacheStartTimestamp, + allowedCacheEndTimestamp, + ) => { + if ( + cachedEventsStartTimestamp < allowedCacheEndTimestamp && + cachedEventsEndTimestamp > allowedCacheStartTimestamp + ) { + if (cachedEventsStartTimestamp < allowedCacheStartTimestamp) { + cachedEventsStartTimestamp = allowedCacheStartTimestamp; + } + if (cachedEventsEndTimestamp > allowedCacheEndTimestamp) { + cachedEventsEndTimestamp = allowedCacheEndTimestamp; + } + } + }; + + return { events: allEvents, lv, reset }; +} + +const mergePromiseElements = (n, o) => { + if (Array.isArray(n)) return o.concat(n); + return (o.push(n), o); +}; + +const getDateFromTimestamp = (timestamp) => { + return luxon.DateTime.fromMillis(timestamp).toFormat("yyyy-MM-dd"); +}; + +const getISODateFromTimestamp = (timestamp) => { + return luxon.DateTime.fromMillis(timestamp).toISO(); +}; + +const getTimestampFromISODate = (isoDate) => { + return luxon.DateTime.fromISO(isoDate).ts; +}; + +const getLuxonDateFromMillis = (millis) => { + return luxon.DateTime.fromMillis(millis); +}; diff --git a/public/js/directives/Calendar/Click.js b/public/js/directives/Calendar/Click.js index e8ace013a..71b12c429 100644 --- a/public/js/directives/Calendar/Click.js +++ b/public/js/directives/Calendar/Click.js @@ -69,6 +69,7 @@ export default { if (binding.arg == 'container') removeContainerListener(el); else + if (binding.arg != 'container') removeClickListener(el); } }