diff options
| author | Moritz Strohm <strohm@data-quest.de> | 2026-01-16 09:36:16 +0000 |
|---|---|---|
| committer | Moritz Strohm <strohm@data-quest.de> | 2026-01-16 09:36:16 +0000 |
| commit | b58142fe5fa1ba1a99d850baa1465df6fa6e0d3b (patch) | |
| tree | d73956252dc17dd054d0db8e0f167a4103b7ba83 /resources | |
| parent | c3e07e221b0bef64d3ad4da48c6371c75ca12cc3 (diff) | |
updated Fullcalendar to version 6, closes #4887
Closes #4887
Merge request studip/studip!4438
Diffstat (limited to 'resources')
19 files changed, 1597 insertions, 1719 deletions
diff --git a/resources/assets/javascripts/bootstrap/fullcalendar.js b/resources/assets/javascripts/bootstrap/fullcalendar.js deleted file mode 100644 index 44786d9..0000000 --- a/resources/assets/javascripts/bootstrap/fullcalendar.js +++ /dev/null @@ -1,48 +0,0 @@ -STUDIP.ready(function () { - //Check if fullcalendar instances are to be displayed: - $('*[data-fullcalendar="1"]').each(function () { - STUDIP.loadChunk('fullcalendar').then(() => { - if (this.calendar === undefined) { - let calendar; - if ($(this).hasClass('semester-plan')) { - calendar = STUDIP.Fullcalendar.createSemesterCalendarFromNode(this); - } else { - calendar = STUDIP.Fullcalendar.createFromNode(this); - } - - continuousRefresh(calendar, 10); - } - }); - }); - - function continuousRefresh(calendar, ttl) { - setTimeout(() => { - calendar.updateSize(); - if (ttl > 0) { - continuousRefresh(calendar, ttl - 1); - } - }, 200); - } - - if ($('#event-color-picker > option').length <= 1) { - var selectedColor = $('#selected-color').val(); - var colors = ['yellow', 'orange', 'red', 'violet', 'dark-violet', 'green', 'dark-green', 'petrol', 'brown']; - - var style = window.getComputedStyle(document.body); - colors.forEach(color => { - let real_color = style.getPropertyValue(`--${color}`).trim(); - $('#event-color-picker').append([ - $('<input type="radio" name="event_color">').attr({ - id: color, - value: real_color, - checked: selectedColor === real_color - }), - $('<label>').attr('for', color).css({ - backgroundColor: `var(--${color})` - }), - ]); - }); - } - - jQuery(document).on('change', '#date_select[data-calendar-control]', STUDIP.Fullcalendar.submitDatePicker); -}); diff --git a/resources/assets/javascripts/bootstrap/resources.js b/resources/assets/javascripts/bootstrap/resources.js index caf9a95..ca16252 100644 --- a/resources/assets/javascripts/bootstrap/resources.js +++ b/resources/assets/javascripts/bootstrap/resources.js @@ -134,29 +134,6 @@ STUDIP.ready(function () { ); //Event handlers for the individual booking plan print view: - jQuery('#sidebar .colour-selector').draggable( - { - cursorAt: { - left: 28, top: 15 - }, - appendTo: 'body', - helper: function () { - var dragged_item = jQuery( - '<div class="dragged-colour"></div>' - ); - jQuery(dragged_item).css( - { - backgroundColor: jQuery(this).css('background-color'), - width: jQuery(this).css('width'), - height: jQuery(this).css('height'), - zIndex: 1000 - } - ); - return dragged_item; - }, - revert: true - } - ); jQuery(document).on( 'click', @@ -177,35 +154,6 @@ STUDIP.ready(function () { } ); - jQuery(document).on( - 'dragenter', - '.individual-booking-plan .appointment-booking-plan', - function (event) { - jQuery(event.target).css('opacity', '0.7'); - } - ); - - jQuery(document).on( - 'dragleave', - '.individual-booking-plan .appointment-booking-plan', - function (event) { - jQuery(event.target).css('opacity', '1.0'); - } - ); - - jQuery(document).on( - 'dragend', - '.dragged-colour', - function (event) { - jQuery(event.target).css( - { - 'top': '0px', - 'left': '0px' - } - ); - } - ); - //For the message functionality of the resource system: jQuery(document).on( @@ -442,24 +390,6 @@ STUDIP.ready(function () { ); jQuery(document).on( - 'click', - '.fc-button', - function () { - if ($(this).hasClass('fc-dayGridMonth-button')) { - updateViewURL('dayGridMonth') - } else if ($(this).hasClass('fc-timeGridWeek-button')) { - updateViewURL('timeGridWeek') - } else if ($(this).hasClass('fc-timeGridDay-button')) { - updateViewURL('timeGridDay') - } else if ($(this).hasClass('fc-today-button') - || $(this).hasClass('fc-prev-button') - || $(this).hasClass('fc-next-button')) { - STUDIP.Fullcalendar.updateDateURL(); - } - } - ); - - jQuery(document).on( 'blur', '.hasDatepicker', function () { @@ -553,137 +483,12 @@ STUDIP.ready(function () { }); } - function updateViewURL(defaultView) { - const url = new URL(window.location.href); - url.searchParams.set('defaultView', defaultView); - - // Push current view url to history - history.pushState({}, null, url.toString()); - - // Set links accordingly - url.searchParams.delete('allday'); - $('.booking-plan-std_view').attr('href', url.toString()); - - url.searchParams.set('allday', 1); - $('.booking-plan-allday_view').attr('href', url.toString()); - } - - - jQuery('#booking-plan-jmpdate').datepicker( - { - dateFormat: 'dd.mm.yy', - onClose: STUDIP.Fullcalendar.submitDatePicker - } - ); jQuery('.resource-booking-time-fields input[type="date"]').datepicker( { dateFormat: 'yy-mm-dd' } ); - jQuery('.resource-plan[data-resources-fullcalendar="1"]').each(function () { - STUDIP.loadChunk('fullcalendar').then(() => { - //Get the default date from the sessionStorage, if it is set - //and no date is specified in the url. - let use_session_date = true; - let url_param_string = window.location.search; - if (url_param_string) { - let url_params = new URLSearchParams(url_param_string); - if (url_params.get('defaultDate')) { - use_session_date = false; - } - } - if (this.calendar === undefined) { - if (jQuery(this).hasClass('semester-plan')) { - STUDIP.Fullcalendar.createSemesterCalendarFromNode( - this, - { - loading: function (isLoading) { - if (!isLoading) { - let h = jQuery('section.studip-fullcalendar-header'); - if (h) { - jQuery(h).removeClass('invisible'); - jQuery(h).insertBefore(this); - } - } - } - } - ); - } else { - let config = { - studip_functions: { - drop_event: - STUDIP.Resources.dropEventInRoomGroupBookingPlan, - resize_event: - STUDIP.Resources.resizeEventInRoomGroupBookingPlan - }, - loading: function (isLoading) { - if (!isLoading) { - let h = jQuery('section.studip-fullcalendar-header'); - if (h) { - jQuery(h).removeClass('invisible'); - jQuery(h).insertBefore(this); - } - } - } - }; - if (use_session_date) { - let session_date_string = sessionStorage.getItem('booking_plan_date'); - if (session_date_string) { - config.defaultDate = session_date_string; - } - } - STUDIP.Fullcalendar.createFromNode(this, config); - } - } - }); - }); - - //Check if an individual booking plan is to be displayed: - jQuery('.individual-booking-plan[data-resources-fullcalendar="1"]').each(function () { - STUDIP.loadChunk('fullcalendar').then(() => { - STUDIP.Fullcalendar.createFromNode( - this, - { - eventPositioned: function (info) { - jQuery(info.el).droppable({ - drop: function (event, ui_element) { - event.preventDefault(); - - let booking_plan_entry = event.target; - let new_background_colour = jQuery( - ui_element.helper - ).css('background-color'); - - jQuery(booking_plan_entry).css( - 'background-color', - new_background_colour - ); - jQuery(booking_plan_entry).css( - 'border-color', - new_background_colour - ); - - jQuery(booking_plan_entry).find('dl').css({ - backgroundColor: new_background_colour, - borderColor: new_background_colour - }); - jQuery(booking_plan_entry).find('dt').css( - 'background-color', - new_background_colour - ); - } - }); - let h = jQuery('section.studip-fullcalendar-header').clone(); - if (h) { - jQuery(h).removeClass('invisible'); - jQuery(h).insertBefore(this); - } - } - } - ); - }); - }); jQuery(document).on( 'click', diff --git a/resources/assets/javascripts/chunk-loader.js b/resources/assets/javascripts/chunk-loader.js index 8cb692c..e822b8e 100644 --- a/resources/assets/javascripts/chunk-loader.js +++ b/resources/assets/javascripts/chunk-loader.js @@ -47,13 +47,6 @@ export const loadChunk = function (chunk, { silent = false } = {}) { ).then(({ default: Chartist }) => Chartist); break; - case 'fullcalendar': - promise = import( - /* webpackChunkName: "fullcalendar" */ - './chunks/fullcalendar' - ); - break; - case 'tablesorter': promise = import( /* webpackChunkName: "tablesorter" */ diff --git a/resources/assets/javascripts/chunks/fullcalendar.js b/resources/assets/javascripts/chunks/fullcalendar.js deleted file mode 100644 index ee28099..0000000 --- a/resources/assets/javascripts/chunks/fullcalendar.js +++ /dev/null @@ -1,11 +0,0 @@ -import '@fullcalendar/core/main.css'; -import '@fullcalendar/daygrid/main.css'; -import '@fullcalendar/timegrid/main.css'; -import '@fullcalendar/timeline/main.css'; -import '@fullcalendar/resource-timeline/main.css'; - -import "../../stylesheets/fullcalendar.scss"; - -import Fullcalendar from '../lib/fullcalendar.js'; - -STUDIP.Fullcalendar = Fullcalendar; diff --git a/resources/assets/javascripts/entry-base.js b/resources/assets/javascripts/entry-base.js index 02fd471..ad114f1 100644 --- a/resources/assets/javascripts/entry-base.js +++ b/resources/assets/javascripts/entry-base.js @@ -67,7 +67,6 @@ import "./bootstrap/tabbable_widget.js" import "./bootstrap/clipboard.js" import "./bootstrap/resources.js" import "./bootstrap/resource-tree-widget.js" -import "./bootstrap/fullcalendar.js" import "./bootstrap/gradebook.js" import "./bootstrap/blubber.js" import "./bootstrap/consultations.js" diff --git a/resources/assets/javascripts/init.js b/resources/assets/javascripts/init.js index 9fcfbb1..a48c028 100644 --- a/resources/assets/javascripts/init.js +++ b/resources/assets/javascripts/init.js @@ -20,7 +20,7 @@ import CourseWizard from './lib/course_wizard.js'; import { createURLHelper } from './lib/url_helper'; import CSS from './lib/css.js'; import Dates from './lib/dates.js'; -import DateTime from './lib/datetime.js'; +import DateTime from './lib/datetime.ts'; import Dialog from './lib/dialog.js'; import DragAndDropUpload from './lib/drag_and_drop_upload.js'; import enrollment from './lib/enrollment.js'; @@ -30,7 +30,6 @@ import Files from './lib/files.js'; import FilesDashboard from './lib/files_dashboard.js'; import Folders from './lib/folders.js'; import Forms from './lib/forms.js'; -import Fullcalendar from './lib/fullcalendar.js'; import Fullscreen from './lib/fullscreen.js'; import GlobalSearch from './lib/global_search.js'; import HeaderMagic from './lib/header_magic.js'; @@ -109,7 +108,6 @@ window.STUDIP = _.assign(window.STUDIP || {}, { FilesDashboard, Folders, Forms, - Fullcalendar, Fullscreen, Gettext, GlobalSearch, diff --git a/resources/assets/javascripts/lib/action.ts b/resources/assets/javascripts/lib/action.ts new file mode 100644 index 0000000..4d247f6 --- /dev/null +++ b/resources/assets/javascripts/lib/action.ts @@ -0,0 +1,26 @@ +/** + * The Action class represents an action in Stud.IP with its usual attributes. + */ +class Action +{ + /** + * The URL for the action. + */ + url: string; + + label: string; + + icon_name: string; + + constructor( + url: string, + label: string, + icon_name: string = '' + ) { + this.url = url; + this.label = label; + this.icon_name = icon_name; + } +} + +export {Action}; diff --git a/resources/assets/javascripts/lib/calendar.ts b/resources/assets/javascripts/lib/calendar.ts new file mode 100644 index 0000000..05c2e99 --- /dev/null +++ b/resources/assets/javascripts/lib/calendar.ts @@ -0,0 +1,226 @@ +import {EventImpl} from "@fullcalendar/core/internal"; +import {CalendarOptions, PluginDef} from "@fullcalendar/core"; +import {getLocale} from "./gettext"; +import locale_de from "@fullcalendar/core/locales/de"; +import locale_en_gb from "@fullcalendar/core/locales/en-gb"; +import dayGridPlugin from "@fullcalendar/daygrid"; +import timeGridPlugin from "@fullcalendar/timegrid"; +import resourceTimelinePlugin from "@fullcalendar/resource-timeline"; +import interactionPlugin from "@fullcalendar/interaction"; + +/** + * The EventURLParameters class represents URL parameters for calendar events + * that are passed to Stud.IP controllers to do something with the event. + */ +class EventURLParameters +{ + /** + * The start date of the event. + */ + start: Date | null; + + /** + * The end date of the event. + */ + end: Date | null; + + /** + * Whether the event is an all-day event (true) or not (false). + */ + all_day: boolean; + + /** + * The optional resource-ID of the event. In the Stud.IP context, this can be the same + * as the ID of a Stud.IP resource. + */ + resource_id: string | null; + + /** + * Constructs an instance using a Fullcalendar Event object. + * + * @param event The Fullcalendar event object to construct URL parameters from. + */ + constructor(event?: EventImpl) { + this.resource_id = null; + if (event) { + this.start = event.start; + this.end = event.end; + this.all_day = event.allDay; + } else { + this.start = null; + this.end = null; + this.all_day = false; + } + } + + setStart(start: Date) { + this.start = start; + } + + setEnd(end: Date) { + this.end = end; + } + + setAllDay(all_day: boolean) { + this.all_day = all_day; + } + + setResourceId(resource_id: string) : void { + this.resource_id = resource_id; + } + + /** + * Converts the parameters to a plain JavaScript object to be used + * with the existing code in Stud.IP to fire requests or to open a dialog. + */ + toObject(): object { + return { + start: this.start ? this.start.toISOString() : null, + end: this.end ? this.end.toISOString() : null, + all_day: this.all_day ? '1' : '0', + resource_id: this.resource_id, + }; + } +} + +/** + * The StudipCalendarConfig class provides default values for the Fullcalendar + * configuration that do not need to be set for each calendar instance. + */ +class StudipCalendarConfig +{ + fullcalendar_config: CalendarOptions = {}; + + constructor(config: CalendarOptions) { + this.fullcalendar_config = config; + + //Set fixed options: + this.fullcalendar_config.firstDay = 1; + this.fullcalendar_config.weekNumberCalculation = 'ISO'; + this.fullcalendar_config.height = 'auto'; + this.fullcalendar_config.contentHeight = 'auto'; + this.fullcalendar_config.schedulerLicenseKey = 'GPL-My-Project-Is-Open-Source'; + this.fullcalendar_config.timeZone = 'local'; + + const all_views: Set<string> = new Set<string>(); + if (this.fullcalendar_config.views) { + for (const view of Object.keys(this.fullcalendar_config.views)) { + all_views.add(view); + } + } + if (this.fullcalendar_config.initialView) { + all_views.add(this.fullcalendar_config.initialView); + } + //Load the plugins according to the views, if they are not + //explicitly set: + if (!this.fullcalendar_config.plugins) { + //Add the plugins here so that users of this component + //do not need to add them separately. + const active_plugins: Array<PluginDef> = [interactionPlugin]; + for (const view_name of all_views) { + if (view_name === 'dayGridMonth') { + active_plugins.push(dayGridPlugin); + } else if (view_name === 'timeGridWeek' || view_name === 'timeGridDay') { + active_plugins.push(timeGridPlugin); + } else if (view_name === 'resourceTimelineWeek' + || view_name === 'resourceTimelineDay') { + active_plugins.push(resourceTimelinePlugin) + } + } + this.fullcalendar_config.plugins = active_plugins; + } + + //Fullcalendar needs a short version of the locale: + let short_locale: string = getLocale(); + if (short_locale) { + short_locale = short_locale.replace('_', '-'); + } else { + short_locale = 'de-DE'; + } + this.fullcalendar_config.locales = [locale_de, locale_en_gb]; + + //Make sure the event sources item is an array: + if (!this.fullcalendar_config.eventSources) { + this.fullcalendar_config.eventSources = []; + } + + //Provide defaults for options that can be altered: + if (!this.fullcalendar_config.eventTimeFormat) { + this.fullcalendar_config.eventTimeFormat = { + hour12: false, + hour: '2-digit', + minute: '2-digit' + }; + } + if (!this.fullcalendar_config.slotLabelFormat) { + this.fullcalendar_config.slotLabelFormat = { + hour: 'numeric', + minute: '2-digit', + omitZeroMinute: false + }; + } + + if (!this.fullcalendar_config.nowIndicator) { + this.fullcalendar_config.nowIndicator = true; + } + if (!this.fullcalendar_config.slotMinTime) { + this.fullcalendar_config.slotMinTime = '08:00'; + } + if (!this.fullcalendar_config.slotMaxTime) { + this.fullcalendar_config.slotMaxTime = '20:00'; + } + if (!this.fullcalendar_config.initialDate) { + this.fullcalendar_config.initialDate = new Date(); + } + if (!this.fullcalendar_config.initialView && all_views.has('timeGridWeek')) { + //At the moment, there is only one good default for timeGridWeek. + this.fullcalendar_config.initialView = 'timeGridWeek'; + } + if (this.fullcalendar_config.allDaySlot === undefined) { + this.fullcalendar_config.allDaySlot = false; + } + if (this.fullcalendar_config.allDayText === undefined) { + this.fullcalendar_config.allDayText = ''; + } + if (this.fullcalendar_config.weekNumbers === undefined) { + this.fullcalendar_config.weekNumbers = true; + } + if (this.fullcalendar_config.headerToolbar) { + //Check if the start and end item in the toolbar is empty. + //In that case, navigation is not desired because it is + //a semester calendar. + const toolbar = this.fullcalendar_config.headerToolbar; + if ((!toolbar.start || toolbar.start.length < 1) + && (!toolbar.end || toolbar.end.length < 1) + && this.fullcalendar_config.views) { + + //No navigation items present and no day header format set. + //This is a semester calendar. Deactivate the display of + //specific dates and display only the day(s) of the week. + if (this.fullcalendar_config.views.timeGridWeek) { + this.fullcalendar_config.views.timeGridWeek.dayHeaderFormat = { + weekday: 'long' + }; + } + if (this.fullcalendar_config.views.timeGridDay) { + this.fullcalendar_config.views.timeGridDay.dayHeaderFormat = { + weekday: 'long' + }; + } + } + } else { + //No toolbar defined. Define a standard one: + this.fullcalendar_config.headerToolbar = { + start: Array.from(all_views).join(','), + center: 'title', + end: 'prev,today,next' + }; + } + } + + getConfig() : CalendarOptions { + return this.fullcalendar_config; + } +} + +export {EventURLParameters, StudipCalendarConfig}; diff --git a/resources/assets/javascripts/lib/datetime.js b/resources/assets/javascripts/lib/datetime.ts index 04d7260..aa8a0bc 100644 --- a/resources/assets/javascripts/lib/datetime.js +++ b/resources/assets/javascripts/lib/datetime.ts @@ -1,16 +1,18 @@ -import { $gettext } from "./gettext.ts"; +import {$gettext, $gettextInterpolate} from "./gettext"; -const DateTime = { +class DateTime +{ /** - * A helper method for padding strings with leading zeros. - * @param what The date to pad. - * @param length The length of the string to output. + * A helper method for padding parts of dates with leading zeros. + * @param item The part of a date string to pad. + * @param target_length The length of the string to output. * @returns {string} A padded version of $what. */ - pad(what, length = 2) { - return `00000000${what}`.substr(-length); - }, + pad(item: number, target_length: number = 2) : string { + const target: string = `00000000${item}`; + return target.substring(target.length - target_length); + } /** * Returns an ISO representation of the specified Date object. @@ -19,9 +21,9 @@ const DateTime = { * @param date The Date object to format as ISO date. * @returns {string} The ISO date string of the Date object. */ - getISODate(date) { - return date.getFullYear() + '-' + this.pad(date.getMonth() + 1) + '-' + date.getDate(); - }, + getISODate(date: Date) : string { + return date.getFullYear() + '-' + this.pad(date.getMonth() + 1) + '-' + this.pad(date.getDate()); + } /** * Returns a formatted version of the specified Date object @@ -36,16 +38,21 @@ const DateTime = { * * @returns {*|string} The date, formatted according to the Stud.IP format for dates. */ - getStudipDate(date, relative_value = false, date_only = false, html = false) { + getStudipDate( + date: Date, + relative_value: boolean = false, + date_only: boolean = false, + html: boolean = false) : string { if (relative_value) { - let now = Date.now(); - if (now - date < 1 * 60 * 1000) { + const now: number = Date.now(); + const date_ts: number = date.getMilliseconds(); + if (now - date_ts < 60 * 1000) { return $gettext('Jetzt'); } - if (now - date < 2 * 60 * 60 * 1000) { - return $gettext( + if (now - date_ts < 2 * 60 * 60 * 1000) { + return $gettextInterpolate( 'Vor %{ minutes } Minuten', - {minutes: Math.floor((now - date) / (1000 * 60))} + {minutes: Math.floor((now - date_ts) / (1000 * 60))} ); } return this.pad(date.getHours()) + ':' + this.pad(date.getMinutes()); @@ -79,7 +86,56 @@ const DateTime = { return this.pad(date.getDate()) + '.' + this.pad(date.getMonth() + 1) + '.' + date.getFullYear() + ' ' + this.pad(date.getHours()) + ':' + this.pad(date.getMinutes()); } } -}; + + getDayOfWeekName(dow: number, short: boolean = false): string { + if (dow === 0 || dow === 7) { + if (short) { + return $gettext('So.'); + } else { + return $gettext('Sonntag'); + } + } else if (dow === 1) { + if (short) { + return $gettext('Mo.'); + } else { + return $gettext('Montag'); + } + } else if (dow === 2) { + if (short) { + return $gettext('Di.'); + } else { + return $gettext('Dienstag'); + } + } else if (dow === 3) { + if (short) { + return $gettext('Mi.'); + } else { + return $gettext('Mittwoch'); + } + } else if (dow === 4) { + if (short) { + return $gettext('Do.'); + } else { + return $gettext('Donnerstag'); + } + } else if (dow === 5) { + if (short) { + return $gettext('Fr.'); + } else { + return $gettext('Freitag'); + } + } else if (dow === 6) { + if (short) { + return $gettext('Sa.'); + } else { + return $gettext('Samstag'); + } + } else { + return ''; + } + } +} export default DateTime; +export const datetime: DateTime = new DateTime(); diff --git a/resources/assets/javascripts/lib/fullcalendar.js b/resources/assets/javascripts/lib/fullcalendar.js deleted file mode 100644 index 5882460..0000000 --- a/resources/assets/javascripts/lib/fullcalendar.js +++ /dev/null @@ -1,977 +0,0 @@ -/** - * This class contains Stud.IP specific code for the fullcalendar package. - */ - -import { Calendar } from '@fullcalendar/core'; -import deLocale from '@fullcalendar/core/locales/de'; -import enLocale from '@fullcalendar/core/locales/en-gb'; -import interactionPlugin from '@fullcalendar/interaction'; -import { Draggable } from '@fullcalendar/interaction'; -import dayGridPlugin from '@fullcalendar/daygrid'; -import timeGridPlugin from '@fullcalendar/timegrid'; -import resourceCommonPlugin from '@fullcalendar/resource-common'; -import resourceTimeGridPlugin from '@fullcalendar/resource-timegrid'; -import resourceTimelinePlugin from '@fullcalendar/resource-timeline'; - -import { jsPDF } from 'jspdf'; -import html2canvas from 'html2canvas'; -import Responsive from "./responsive"; - -Date.prototype.getWeekNumber = function () { - var d = new Date(Date.UTC(this.getFullYear(), this.getMonth(), this.getDate())); - var dayNum = d.getUTCDay() || 7; - d.setUTCDate(d.getUTCDate() + 4 - dayNum); - var yearStart = new Date(Date.UTC(d.getUTCFullYear(),0,1)); - return Math.ceil((((d - yearStart) / 86400000) + 1)/7); -}; - -function pad(what, length = 2, char = '0') { - let padding = new Array(length + 1).join(char); - return `${padding}${what}`.substr(-length); -} - -class Fullcalendar -{ - static holidayCache = sessionStorage.getItem('fullcalendar_holidays') ? JSON.parse(sessionStorage.getItem('fullcalendar_holidays')) : {}; - static vacationCache = sessionStorage.getItem('fullcalendar_vacations') ? JSON.parse(sessionStorage.getItem('fullcalendar_vacations')) : {}; - - static loadHolidays(year) { - if (this.holidayCache[year]) { - return Promise.resolve(this.holidayCache[year]); - } - - return STUDIP.jsonapi.withPromises().get('holidays', { - data: { 'filter[year]': year } - }).then(response => { - const events = []; - if (!response) { - return events; - } - - for (const [date, data] of Object.entries(response)) { - const classNames = ['holiday']; - if (data.mandatory) { - classNames.push('official'); - } - - const day = new Date(date); - events.push({ - // Note: Since allDay is set to true, the start and end time is ignored. - // See the documentation: https://fullcalendar.io/docs/v4/event-parsing - start: day, - end: day, - allDay: true, - title: data.holiday, - editable: false, - - classNames, - - // Note: Colours are set via SCSS. - textColor: '', - color: '', - borderColor: '', - - rendering: 'background' - }); - } - - this.holidayCache[year] = events; - - sessionStorage.setItem('fullcalendar_holidays', JSON.stringify(this.holidayCache)); - - return events; - }); - } - - static loadVacations(year) { - if (this.vacationCache[year]) { - return Promise.resolve(this.vacationCache[year]); - } - - return STUDIP.jsonapi.withPromises().get('vacations', { - data: {'filter[year]': year} - }).then(response => { - if (!response) { - return []; - } - - const items = []; - - for (const vacation_data of Object.values(response)) { - const start = new Date(parseInt(vacation_data.start) * 1000); - const end = new Date(parseInt(vacation_data.end) * 1000); - items.push({ - start, - end, - allDay: true, - title: vacation_data.name, - editable: false, - classNames: ['holiday'], - - // Note: Colours are set via SCSS. - textColor: '', - color: '', - borderColor: '', - - rendering: 'background' - }); - } - - this.vacationCache[year] = items; - - sessionStorage.setItem('fullcalendar_vacations', JSON.stringify(this.vacationCache)); - - return items; - }); - } - - /** - * The initialisation method. It loads the JS files for fullcalendar - * in case they are not loaded and sets up a fullcalendar instance - * for the nodes specified in the parameter node. - * - * @param DOMElement|string node The node which shall have a full calendar. - * This must either be a DOMElement or a string - * containing a CSS selector. - */ - static init(node, fullcalendar_options = null) - { - // Convert css selector to actual dom element - node = $(node)[0]; - - if (!node) { - //We need a CSS selector or a node! - return; - } - - if (document.getElementById('external-events')) { - new Draggable(document.getElementById('external-events'), { - itemSelector: '.fc-event', - eventData (eventEl) { - return { - title: eventEl.dataset.eventTitle, - duration: eventEl.dataset.eventDuration, - course_id: eventEl.dataset.eventCourse, - tooltip: eventEl.dataset.eventTooltip, - studip_api_urls: {drop: eventEl.dataset.eventDropUrl}, - studip_view_urls: {edit: eventEl.dataset.eventDetailsUrl} - }; - } - }); - } - - var calendar = new Calendar(node, fullcalendar_options); - node.calendar = calendar; - calendar.render(); - - return calendar; - } - - /** - * Converts semester events to the default fullcalendar event format. - * The begin and end date are converted to fit into the current week. - */ - static convertSemesterEvents(event_data, fake_week_start = Date()) - { - if (!event_data) { - return {}; - } - - var start = String(event_data.start).split('T'); - var end = String(event_data.end).split('T'); - - //start and end must be transformed to the current week. - //Therefore, we need the ISO weekdays for begin and end. - var fake_start = new Date(fake_week_start); - fake_start.setHours(12); - fake_start.setMinutes(0); - fake_start.setSeconds(0); - var fake_end = new Date(fake_week_start); - fake_end.setHours(12); - fake_end.setMinutes(0); - fake_end.setSeconds(0); - - //Calculcate the week day of the current week for the event - //from the current day and convert sunday to ISO format - var start_day_diff = fake_start.getDay() || 7; - var end_day_diff = fake_end.getDay() || 7; - - start_day_diff = start_day_diff - event_data.studip_weekday_begin; - end_day_diff = end_day_diff - event_data.studip_weekday_end; - - fake_start = new Date( - fake_start.getTime() - start_day_diff * 24 * 60 * 60 * 1000 - ); - fake_end = new Date( - fake_end.getTime() - end_day_diff * 24 * 60 * 60 * 1000 - ); - - //Output the modified begin and end date in the correct ISO format: - event_data.start =`${fake_start.getFullYear()}-${pad(fake_start.getMonth() + 1)}-${pad(fake_start.getDate())}T${start[1]}`; - event_data.end = `${fake_end.getFullYear()}-${pad(fake_end.getMonth() + 1)}-${pad(fake_end.getDate())}T${end[1]}`; - - return event_data; - } - - - static createSemesterCalendarFromNode(node, additional_config = {}) - { - if (!node) { - //Ain't no fullcalendar when the node's gone! - return; - } - - var config = $.extend( - {}, - $(node).data('config') || {}, - additional_config - ); - - if (Array.isArray(config.eventSources)) { - config.eventSources = config.eventSources.map((s) => { - if (s.url !== undefined) { - return s; - } - }); - } - - return this.createFromNode(node, config); - } - - - static defaultResizeEventHandler(info) - { - if (!info.event.durationEditable || !info.view.viewSpec.options.editable) { - //Read-only events cannot be resized! - info.revert(); - return; - } - - if (info.event.extendedProps.studip_api_urls.resize) { - $.post({ - url: info.event.extendedProps.studip_api_urls.resize, - async: false, - data: { - begin: this.toRFC3339String(info.event.start), - end: this.toRFC3339String(info.event.end) - } - }).fail(info.revert); - } else if (info.event.extendedProps.studip_api_urls.resize_dialog) { - STUDIP.Dialog.fromURL( - info.event.extendedProps.studip_api_urls.resize_dialog, - { - data: { - begin: this.toRFC3339String(info.event.start), - end: this.toRFC3339String(info.event.end) - } - } - ); - } - } - - static downloadPDF(format = 'landscape', withWeekend = false) - { - $('*[data-fullcalendar="1"]').each(function () { - if (this.calendar != undefined) { - $(this).addClass('print-view').toggleClass('without-weekend', !withWeekend); - - var title = $(this).data('title'); - let print_title = $('<h1>').text(title).prependTo(this); - - window.scrollTo(0, 0); - - html2canvas(this).then(canvas => { - var imgData = canvas.toDataURL('image/jpeg'); - var pdf = new jsPDF({ - orientation: format === 'landscape' ? 'landscape' : 'portrait' - }); - if (format === 'landscape') { - pdf.addImage(imgData, 'JPEG', 20, 20, 250, 250, 'i1', 'NONE', 0); - } else { - pdf.addImage(imgData, 'JPEG', 25, 20, 160, 190, 'i1', 'NONE', 0); - } - pdf.save(title + '.pdf'); - }); - - print_title.remove(); - $(this).removeClass('print-view without-weekend'); - } - }); - } - - static toRFC3339String(date) - { - var timezone_offset_min = date.getTimezoneOffset(); - var offset_hrs = parseInt(Math.abs(timezone_offset_min / 60), 10); - var offset_min = Math.abs(timezone_offset_min%60); - var timezone_standard; - - offset_hrs = pad(offset_hrs); - offset_min = pad(offset_min); - - // Add an opposite sign to the offset - // If offset is 0, it means timezone is UTC - if (timezone_offset_min < 0) { - timezone_standard = `+${offset_hrs}:${offset_min}`; - } else if (timezone_offset_min > 0) { - timezone_standard = `-${offset_hrs}:${offset_min}`; - } else { - timezone_standard = '+00:00'; - } - - var current_date = pad(date.getDate()); - var current_month = pad(date.getMonth() + 1); - var current_year = date.getFullYear(); - var current_hrs = pad(date.getHours()); - var current_mins = pad(date.getMinutes()); - var current_secs = pad(date.getSeconds()); - var current_datetime; - - // Current datetime - // String such as 2016-07-16T19:20:30 - current_datetime = `${current_year}-${current_month}-${current_date}T${current_hrs}:${current_mins}:${current_secs}`; - - return current_datetime + timezone_standard; - } - - static defaultDropEventHandler(info) - { - // The logic from fullcalendar is inversed here: - // If the calendar isn't editable, the event isn't either. - if (!info.event.startEditable || !info.view.viewSpec.options.editable) { - //Read-only events cannot be dragged and dropped! - info.revert(); - return; - } - - var drop_resource_id = info.newResource ? info.newResource.id : info.event.extendedProps.studip_range_id; - - if (info.event.extendedProps.studip_api_urls.move || info.event.extendedProps.studip_api_urls.move_dialog) { - let move_dialog = info.event.extendedProps.studip_api_urls.move_dialog; - if (info.event.allDay) { - if (move_dialog) { - STUDIP.Dialog.fromURL( - move_dialog, - { - data: { - resource_id: drop_resource_id, - begin: this.toRFC3339String(info.event.start.setHours(0, 0, 0)), - end: this.toRFC3339String(info.event.start.setHours(23, 59, 59)) - }, - size: 'auto' - } - ); - } else { - jQuery.post({ - async: false, - url: info.event.extendedProps.studip_api_urls.move, - data: { - resource_id: drop_resource_id, - begin: this.toRFC3339String(info.event.start.setHours(0, 0, 0)), - end: this.toRFC3339String(info.event.start.setHours(23, 59, 59)) - } - }).fail(info.revert); - } - } else if (info.event.end === null) { - let real_end = new Date(); - real_end.setTime(info.event.start.getTime()); - real_end.setHours(info.event.start.getHours()+2); - if (move_dialog) { - STUDIP.Dialog.fromURL( - move_dialog, - { - data: { - resource_id: drop_resource_id, - begin: this.toRFC3339String(info.event.start), - end: this.toRFC3339String(real_end) - }, - size: 'auto' - } - ); - } else { - jQuery.post({ - async: false, - url: info.event.extendedProps.studip_api_urls.move, - data: { - resource_id: drop_resource_id, - begin: this.toRFC3339String(info.event.start), - end: this.toRFC3339String(real_end) - } - }).fail(info.revert); - } - } else { - if (move_dialog) { - STUDIP.Dialog.fromURL( - move_dialog, - { - data: { - resource_id: drop_resource_id, - begin: this.toRFC3339String(info.event.start), - end: this.toRFC3339String(info.event.end) - }, - size: 'auto' - } - ); - } else { - jQuery.post({ - async: false, - url: info.event.extendedProps.studip_api_urls.move, - data: { - resource_id: drop_resource_id, - begin: this.toRFC3339String(info.event.start), - end: this.toRFC3339String(info.event.end) - } - }).fail(info.revert); - } - } - } - } - - static institutePlanDropEventHandler(info) - { - //The logic from fullcalendar is inversed here: - if (info.newResource) { - $.post({ - async: false, - url: info.event.extendedProps.studip_api_urls.move, - data: { - cycle_id: info.event.id, - resource_id: info.newResource.id, - begin: this.toRFC3339String(info.event.start), - end: this.toRFC3339String(info.event.end) - } - }).fail(info.revert); - } else { - //If the calendar isn't editable, the event isn't either. - if (!info.event.startEditable || !info.view.viewSpec.options.editable) { - //Read-only events cannot be dragged and dropped! - info.revert(); - return; - } - - $.post({ - async: false, - url: info.event.extendedProps.studip_api_urls.move, - data: { - cycle_id: info.event.id, - begin: this.toRFC3339String(info.event.start), - end: this.toRFC3339String(info.event.end) - } - }).fail(info.revert); - } - } - - static institutePlanExternalDropEventHandler(info) - { - var resourceIds = info.event.getResources().map(resource => resource.id); - - $.post({ - async: false, - url: info.event.extendedProps.studip_api_urls.drop, - data: { - course_id: info.event.extendedProps.course_id, - begin: this.toRFC3339String(info.event.start), - end: this.toRFC3339String(info.event.end), - resource_id: resourceIds[0] - } - }).done(data => { - if (data) { - info.view.context.calendar.addEvent(JSON.parse(data)); - info.event.remove(); - } - }); - } - - static createFromNode(node, additional_config = {}) - { - if (!node) { - //No node? No fullcalendar! - return; - } - - let config = $(node).data('config'); - - let defaultView = 'timeGridWeek'; - if (Responsive.isResponsive() && config.responsiveDefaultView !== undefined) { - defaultView = config.responsiveDefaultView; - } else if (config.defaultView !== undefined) { - defaultView = config.defaultView; - } - - //Make sure the default values are set, if they are not found - //in the additional_config object: - config = $.extend({ - plugins: [ interactionPlugin, dayGridPlugin, timeGridPlugin, resourceCommonPlugin, resourceTimeGridPlugin, resourceTimelinePlugin ], - schedulerLicenseKey: 'GPL-My-Project-Is-Open-Source', - header: { - left: 'dayGridMonth,timeGridWeek,timeGridDay' - }, - minTime: '08:00:00', - maxTime: '20:00:00', - validRange: { - start: '1970-01-01' - }, - height: 'auto', - contentHeight: 'auto', - firstDay: 1, - weekNumberCalculation: 'ISO', - locales: [enLocale, deLocale ], - locale: String.locale === 'de-DE' ? 'de' : 'en-gb', - timeFormat: 'H:mm', - slotLabelFormat: { - hour: 'numeric', - minute: '2-digit', - omitZeroMinute: false - }, - columnHeaderHtml: STUDIP.Fullcalendar.renderDateForColumn, - nowIndicator: true, - timeZone: 'local', - studip_functions: [], - resourceAreaWidth: '20%', - select (selectionInfo) { - let calendar_config = JSON.parse(selectionInfo.view.context.calendar.el.dataset.config); - let dialog_size = 'auto'; - if (calendar_config.dialog_size !== undefined) { - dialog_size = calendar_config.dialog_size; - } - - if (!selectionInfo.view.viewSpec.options.editable || !selectionInfo.view.viewSpec.options.studip_urls) { - //The calendar isn't editable. - return; - } - if (selectionInfo.view.viewSpec.options.studip_urls.add) { - if (selectionInfo.resource) { - STUDIP.Dialog.fromURL( selectionInfo.view.viewSpec.options.studip_urls.add, { - data: { - begin: selectionInfo.start.getTime()/1000, - end: selectionInfo.end.getTime()/1000, - ressource_id: selectionInfo.resource.id, - all_day: selectionInfo.allDay ? '1' : '0' - }, - size: dialog_size - }); - } else { - STUDIP.Dialog.fromURL(selectionInfo.view.viewSpec.options.studip_urls.add, { - data: { - begin: selectionInfo.start.getTime()/1000, - end: selectionInfo.end.getTime()/1000, - all_day: selectionInfo.allDay ? '1' : '0' - }, - size: dialog_size - }); - } - } - }, - eventClick (eventClickInfo) { - var event = eventClickInfo.event; - var extended_props = event.extendedProps; - if ($(eventClickInfo.jsEvent.target).hasClass('event-colorpicker')) { - STUDIP.Dialog.fromURL( - STUDIP.URLHelper.getURL('dispatch.php/admin/courseplanning/pick_color/' + extended_props.metadate_id + '/' + config.actionCalled), - {'size': '400x400'} - ); - return false; - } - - - if ($(eventClickInfo.event._calendar.el).hasClass('request-plan')) { - if (extended_props.request_id && extended_props.studip_view_urls.edit) { - STUDIP.Dialog.fromURL( - STUDIP.URLHelper.getURL(extended_props.studip_view_urls.edit) - ); - } else if(extended_props.studip_parent_object_class == 'ResourceBooking' && $.inArray('for-course', event._def.ui.classNames) != -1) { - STUDIP.Dialog.fromURL( - STUDIP.URLHelper.getURL('dispatch.php/resources/room_request/rerequest_booking/' + extended_props.studip_parent_object_id) - ); - } - return false; - } - - if (extended_props.studip_view_urls === undefined) { - return; - } - let calendar_config = JSON.parse(eventClickInfo.view.context.calendar.el.dataset.config); - let dialog_size = 'auto'; - if (calendar_config.dialog_size !== undefined) { - //Use the configured default dialog size for the fullcalendar instance: - dialog_size = calendar_config.dialog_size; - } - if (extended_props.dialog_size !== undefined) { - //Use the dialog size of the event: - dialog_size = extended_props.dialog_size; - } - if (!event.startEditable && extended_props.studip_view_urls.show) { - STUDIP.Dialog.fromURL( - STUDIP.URLHelper.getURL(extended_props.studip_view_urls.show), - {size: dialog_size} - ); - } else if (event.startEditable) { - if (extended_props.studip_view_urls.edit) { - STUDIP.Dialog.fromURL( - STUDIP.URLHelper.getURL(extended_props.studip_view_urls.edit), - {size: dialog_size} - ); - } else if (extended_props.studip_view_urls.show) { - STUDIP.Dialog.fromURL( - STUDIP.URLHelper.getURL(extended_props.studip_view_urls.show), - {size: dialog_size} - ); - } - } - return false; - }, - eventResize (info) { - // The logic from fullcalendar is inversed here: - // If the calendar isn't editable, the event isn't either. - if (info.view.viewSpec.options.studip_functions.resize_event) { - info.view.viewSpec.options.studip_functions.resize_event(info); - } else { - STUDIP.Fullcalendar.defaultResizeEventHandler(info); - } - info.event.source.refetch(); - }, - eventDrop (info) { - let handle_drop = function() { - if ($(info.view.context.calendar.el).hasClass('institute-plan')) { - var start = info.event.start; - var cal_start = info.view.activeStart; - if ((start.getHours() - cal_start.getHours()) % 2 === 1) { - info.event.moveDates('-01:00'); - } - STUDIP.Fullcalendar.institutePlanDropEventHandler(info); - } else { - if (info.view.viewSpec.options.studip_functions.drop_event) { - info.view.viewSpec.options.studip_functions.drop_event(info); - } else { - STUDIP.Fullcalendar.defaultDropEventHandler(info); - } - info.event.source.refetch(); - } - }; - - let calendar_config = JSON.parse(info.view.context.calendar.el.dataset.config); - if (calendar_config.confirm) { - if (calendar_config.confirm.drop) { - STUDIP.Dialog.confirm(calendar_config.confirm.drop) - .done(handle_drop) - .fail(function() { - //Revert the dropped element: - info.revert(); - }); - } else { - handle_drop(); - } - } else { - handle_drop(); - } - }, - eventRender (info) { - let event = info.event; - let eventElement = info.el; - let iconColor = event.textColor === '#000000' ? 'black' : 'white'; - - if ($(info.view.context.calendar.el).hasClass('institute-plan')) { - $(eventElement).attr('title', event.extendedProps.tooltip); - if (event.extendedProps.content_fields) { - for (const [css_class, field] of Object.entries(event.extendedProps.content_fields)) { - $(eventElement).find('.fc-content').append( - $('<div>').css({ - width: 'calc(100% - 21px)', - height: '100%', - wordBreak: 'break-word' - }).text(field) - .addClass(css_class + ' fc-title') - ); - } - } - $(eventElement).find('.fc-content').append( - $('<button class="event-colorpicker">').addClass(iconColor) - ); - } else { - $(eventElement).attr('title', event.title); - } - - if (event.extendedProps.icon) { - //Check if there is more than one icon: - let event_icons = event.extendedProps.icon.split(','); - let title = $(eventElement).find('.fc-title'); - for (let icon of event_icons) { - //Check if the icon is already a URL or just the name of an icon. - let iconUrl = ''; - if (icon.includes('://')) { - //The icon is already a URL. - iconUrl = icon; - } else { - //The icon is just referenced by its name. We do not need a specific color here, background-color is currentColor. - iconUrl = `${STUDIP.ASSETS_URL}images/icons/black/${icon}.svg` - } - //Add the icons as spans in front of the content: - let icon_element = $('<span class="icon"></span>'); - icon_element.css('--icon-url', `url('${iconUrl}')`); - title.prepend(icon_element); - } - } - - //If a background event with title shall be rendered, we have to check - //if an all-day slot is present or not. - let generate_title = false; - if (event.rendering === 'background' && event.title && event.allDay) { - if (info.view.viewSpec.options.allDaySlot === true) { - //An all-day slot is present in the calendar. - if (info.isStart === true || info.isEnd === true) { - - generate_title = true; - } - } else { - //No all-day slot in the calendar. Display a title - //at the start of the day in the calendar. - if (info.isStart === false || info.isEnd === false) { - generate_title = true; - } - } - } - if (generate_title) { - //Generate a visible title for the background element: - let element = $('<div class="title"></div>'); - $(element).text(event.title); - if (event.textColor) { - element.css('color', event.textColor); - } - $(eventElement).append(element); - } - }, - eventSourceSuccess: function(content) { - if ($(node).hasClass('semester-plan')) { - $(content).each(function(i, event_data){ - STUDIP.Fullcalendar.convertSemesterEvents(event_data, config.defaultDate); - }); - } - return content; - }, - loading (isLoading) { - if (isLoading) { - if (!$('#loading-spinner').length) { - jQuery('#content').append( - $('<div id="loading-spinner" style="position: absolute; top: calc(50% - 55px); left: calc(50% + 135px); z-index: 9001;">').html( - $('<img>').attr('src', STUDIP.ASSETS_URL + 'images/loading-indicator.svg') - .css({ - width: 32, - height: 32 - }) - ) - ); - } - } else { - $('#loading-spinner').remove(); - this.updateSize(); - } - }, - datesRender (info) { - if ($(node).hasClass('schedule') && info.view.type !== 'timeGridDay') { - document.querySelector('.fc-right').style.display = 'none'; - } - - if ($(node).hasClass('semester-plan')) { - //Nothing to do in the semester plan, since it already displays - //all the information on loading. - return; - } - let activeRange = info.view.props.dateProfile.activeRange; - let timestamp = activeRange.start.getTime() / 1000; - if ($(info.el).hasClass('institute-plan')) { - $('.fc-slats tr:odd .fc-widget-content:not(.fc-axis)').remove(); - } - - if (document.getElementById('booking-plan-header-semname') === null) { - return; - } - - $.getJSON( - STUDIP.URLHelper.getURL(`dispatch.php/resources/ajax/semester_week/${timestamp}`) - ).done((data) => { - if (data) { - $('#booking-plan-header-semname').text(data.semester_name); - if (data.sem_week) { - $('#booking-plan-header-semweek').text(data.sem_week); - $('#booking-plan-header-semweek-part').show(); - } else { - $('#booking-plan-header-semweek').text(''); - $('#booking-plan-header-semweek-part').hide(); - } - $('#booking-plan-header-semrow').show(); - $('#booking-plan-header-calweek').text(data.week_number); - $('#booking-plan-header-calbegin').text('(' + data.current_day + ')'); - } else { - $('#booking-plan-header-semrow').hide(); - $('#booking-plan-header-semweek-part').hide(); - } - }); - }, - resourceRender (renderInfo) { - if ($(renderInfo.view.context.calendar.el).hasClass('room-group-booking-plan')) { - let action = $(renderInfo.view.context.calendar.el).hasClass('semester-plan') ? 'semester' : 'booking'; - let url = STUDIP.URLHelper.getURL(`dispatch.php/resources/room_planning/${action}_plan/${renderInfo.resource.id}`); - $(renderInfo.el).find('.fc-cell-text').html( - $('<a>').attr('href', url).text(renderInfo.resource.title) - ); - } else if ($("*[data-fullcalendar='1']").hasClass('institute-plan') && renderInfo.resource.id > 0) { - const icon = '<span class="btn-icon btn-icon--edit icon-role-clickable" aria-label="edit"></span>'; - $(renderInfo.el).append( - '<a href="' - + STUDIP.URLHelper.getURL('dispatch.php/admin/courseplanning/rename_column/' - + renderInfo.resource.id - +'/' - + renderInfo.view.activeStart.getDay()) - + '" data-dialog="size=auto"> ' - + icon - + '</a>' - ); - } - }, - drop (dropInfo) { - $(dropInfo.draggedEl).remove(); - }, - eventReceive (info) { - if ($(info.view.context.calendar.el).hasClass('institute-plan')) { - STUDIP.Fullcalendar.institutePlanExternalDropEventHandler(info); - } - } - }, config); - - //Special treatment: If a general column header format is set, - //in the configuration, it shall be used for all columns in all views - //by using a special columnHeaderHtml function. - if (config.columnHeaderFormat) { - config.columnHeaderHtml = function (date) { - if ($("*[data-fullcalendar='1']").hasClass('institute-plan')) { - return '<a href="' + STUDIP.URLHelper.getURL('dispatch.php/admin/courseplanning/weekday/' + date.getDay()) + '">' + date.toLocaleDateString('de-DE', config.columnHeaderFormat) + '</a>'; - } else { - return date.toLocaleDateString('de-DE', config.columnHeaderFormat); - } - }; - } - - config = $.extend({}, config, additional_config); - - if (!Array.isArray(config.eventSources)) { - config.eventSources = []; - } - config.eventSources.push({ - events: (fetchInfo, successCallback, failureCallback) => { - const startYear = fetchInfo.start.getFullYear(); - const endYear = fetchInfo.end.getFullYear(); - const requests = []; - for (let year = startYear; year <= endYear; year++) { - requests.push(this.loadHolidays(year)); - requests.push(this.loadVacations(year)); - } - Promise.all(requests).then(results => { - const events = [].concat(...results); - successCallback(events); - return results; - }).catch(failureCallback); - }, - }); - - config.defaultView = defaultView; - - return this.init(node, config); - } - - static submitDatePicker() { - let picked_date = jQuery('#booking-plan-jmpdate').val(); - if (!picked_date) { - //Not a booking plan date selector. - picked_date = jQuery('#date_select').val(); - } - let iso_date_string = ''; - if (picked_date) { - if (picked_date.includes('.')) { - let [day, month, year] = picked_date.split('.'); - iso_date_string = year.padStart(4, '20') + '-' + month.padStart(2, '0') + '-' + day.padStart(2, '0'); - } else if (picked_date.includes('/')) { - let [day, month, year] = picked_date.split('/'); - iso_date_string = year.padStart(4, '20') + '-' + month.padStart(2, '0') + '-' + day.padStart(2, '0'); - } else if (picked_date.includes('-')) { - iso_date_string = picked_date; - } - } - if (iso_date_string) { - jQuery('[data-fullcalendar="1"],[data-resources-fullcalendar="1"]').each(function () { - this.calendar.gotoDate(iso_date_string); - }); - Fullcalendar.updateDateURL(); - } - } - - static updateDateURL() { - let changedMoment; - jQuery('[data-fullcalendar="1"],[data-resources-fullcalendar="1"]').each(function () { - changedMoment = this.calendar.getDate(); - }); - if (changedMoment) { - let changed_date = STUDIP.Fullcalendar.toRFC3339String(changedMoment).split('T')[0]; - //Get the timestamp: - let timestamp = changedMoment.getTime() / 1000; - - jQuery('a.resource-bookings-actions, a.calendar-action').each(function () { - const url = new URL(this.href); - url.searchParams.set('timestamp', timestamp.toString()) - url.searchParams.set('defaultDate', changed_date) - this.href = url.toString(); - }); - jQuery('.sidebar-widget.calendar-action').each(function() { - //Each sidebar widget is different. The placement of the defaultDate URL parameter - //has to reflect that. - jQuery(this).find('button[formaction]').each(function() { - //Modify the formaction attribute: - let url = new URL(jQuery(this).attr('formaction')); - url.searchParams.set('defaultDate', changed_date); - jQuery(this).attr('formaction', url.toString()); - }); - jQuery(this).find('form[action]').each(function() { - //Add a hidden input with the defaultDate: - let hidden_input = jQuery(this).find('input[name="defaultDate"]')[0]; - if (!hidden_input) { - hidden_input = jQuery('<input type="hidden" name="defaultDate">'); - jQuery(this).append(hidden_input); - } - jQuery(hidden_input).val(changed_date); - }); - }); - - // Now change the URL of the window. - const url = new URL(window.location.href); - url.searchParams.set('defaultDate', changed_date); - - // Update url in history - history.pushState({}, null, url.toString()); - - // Adjust links accordingly - url.searchParams.delete('allday'); - jQuery('.booking-plan-std_view').attr('href', url.toString()); - - url.searchParams.set('allday', 1); - jQuery('.booking-plan-allday_view').attr('href', url.toString()); - - // Update sidebar value - let element = jQuery('#booking-plan-jmpdate,#date_select').first(); - let padded_date = pad(changedMoment.getDate(), 2, '0') - + '.' + pad(changedMoment.getMonth() + 1, 2, '0') - + '.' + changedMoment.getFullYear(); - element.val(padded_date); - if (element.is('#booking-plan-jmpdate')) { - //Store the date in the sessionStorage: - sessionStorage.setItem('booking_plan_date', changed_date); - } - } - } - - static renderDateForColumn(date) { - let format = new Intl.DateTimeFormat(String.locale, {weekday: 'short'}); - let date_html = STUDIP.DateTime.getStudipDate(date, false, true, true); - return '<span class="dow">' + format.format(date) + '.</span> ' + date_html; - } -} - -export default Fullcalendar; diff --git a/resources/assets/javascripts/lib/holiday.ts b/resources/assets/javascripts/lib/holiday.ts new file mode 100644 index 0000000..2363eb1 --- /dev/null +++ b/resources/assets/javascripts/lib/holiday.ts @@ -0,0 +1,254 @@ +import {jsonapi} from "./jsonapi"; +import {datetime} from "./datetime"; + +/** + * The Holiday class represents a holiday in Stud.IP + */ +class Holiday +{ + /** + * The day of the holiday. + */ + day: Date; + + /** + * The name of the holiday. + */ + name: string; + + /** + * Whether the holiday is an official holiday (true) or not (false). + * The latter means that it can be part of a vacation period. + */ + official: boolean; + + constructor(day: Date, name: string, official: boolean = true) { + this.day = day; + this.name = name; + this.official = official; + } +} + + +/** + * The HolidayCache class handles retrieving and caching holidays and vacations. + */ +class HolidayCache +{ + holiday_cache: Map<number, Array<Holiday>>; + vacation_cache: Map<number, Array<Holiday>>; + + /** + * The constructor has code to load cached holidays and vacations from the session storage. + */ + constructor() { + //Attempt to restore the cache from the session: + const holiday_cache_str = sessionStorage.getItem('fullcalendar_holidays'); + this.holiday_cache = new Map<number, Array<Holiday>>(); + if (holiday_cache_str != null) { + for (const [year, raw_array] of Object.entries(JSON.parse(holiday_cache_str))) { + const holidays: Array<Holiday> = []; + if (Array.isArray(raw_array)) { + for (const raw_holiday of raw_array) { + holidays.push( + new Holiday(new Date(raw_holiday.day), raw_holiday.name, raw_holiday.official) + ); + } + this.holiday_cache.set(parseInt(year), holidays); + } + } + } + + const vacation_cache_str = sessionStorage.getItem('fullcalendar_vacations'); + this.vacation_cache = new Map<number, Array<Holiday>>(); + if (vacation_cache_str != null) { + for (const [year, raw_array] of Object.entries(JSON.parse(vacation_cache_str))) { + const vacations: Array<Holiday> = []; + if (Array.isArray(raw_array)) { + for (const raw_vacation of raw_array) { + vacations.push( + new Holiday(new Date(raw_vacation.day), raw_vacation.name, raw_vacation.official) + ); + } + this.vacation_cache.set(parseInt(year), vacations); + } + } + } + } + + /** + * Loads the holidays of a year and stores them in the cache. + * + * @param year The year for which to load holidays. + */ + loadHolidays(year: number) : void { + const existing_cache = this.holiday_cache.get(year); + if (existing_cache) { + return; + } + + jsonapi.withPromises().GET('holidays', { + data: { 'filter[year]': year } + }).then(response => { + if (!response) { + return; + } + const events: Array<Holiday> = []; + for (const [date, data] of Object.entries(response)) { + const day = new Date(date); + events.push(new Holiday(day, data.holiday, data.mandatory)); + } + + this.holiday_cache.set(year, events); + //Update the session storage item: + sessionStorage.setItem('fullcalendar_holidays', JSON.stringify(Object.fromEntries(this.holiday_cache))); + }); + } + + /** + * Loads the vacation days of a year and stores them in the cache. + * + * @param year The year for which to load vacations. + */ + loadVacations(year: number): void { + const existing_cache = this.vacation_cache.get(year); + if (existing_cache) { + return; + } + jsonapi.withPromises().get('vacations', { + data: {'filter[year]': year} + }).then(response => { + if (!response) { + return; + } + const events: Array<Holiday> = []; + for (const vacation_data of Object.values(response)) { + for (let i = parseInt(vacation_data.start); i < parseInt(vacation_data.end); i += 86400) { + const day = new Date(i * 1000); + events.push(new Holiday(day, vacation_data.name, false)); + } + } + + this.vacation_cache.set(year, events); + //Update the session storage item: + sessionStorage.setItem('fullcalendar_vacations', JSON.stringify(Object.fromEntries(this.vacation_cache))); + }); + } + + /** + * Returns a vacation day from the cache. + * + * @param date The day of the vacation. + * + * @returns Holiday|null If a vacation exists on the specified day, a Holiday object + * for it is returned. Otherwise, null is returned. + */ + getVacation(date: Date) : Holiday|null { + const year_vacation_cache = this.vacation_cache.get(date.getFullYear()); + if (!year_vacation_cache) { + return null; + } + for (const vacation of year_vacation_cache) { + if (datetime.getISODate(date) === datetime.getISODate(vacation.day)) { + //A vacation day has been found. + return vacation; + } + } + return null; + } + + /** + * Checks if there is a vacation day on the specified date. + * + * @param date The date to check. + * + * @returns boolean True, if the specified day is a vacation day. + * Otherwise, false is returned. + */ + isVacation(date: Date) { + return this.getVacation(date) !== null; + } + + /** + * Returns the name of the vacation day on the specified date. + * + * @param date The date for which to get the vacation name. + * + * @returns string The name of the vacation, if any. + * If there is no vacation day on the specified date, the string is empty. + */ + getVacationName(date: Date) { + const vacation = this.getVacation(date); + if (vacation) { + return vacation.name; + } + return ''; + } + + /** + * Returns a holiday / vacation day from the cache. + * + * @param date The date of the holiday. + * + * @param regard_vacations Whether to regard vacations in addition to holidays (true) + * or to just regard holidays (false). Defaults to false. + * + * @returns Holiday|null If a holiday can be found, it is returned. Otherwise, + * null is returned. + */ + getHoliday(date: Date, regard_vacations: boolean = false): Holiday|null { + //Check if an entry for the date exists in the holiday or the vacation list: + const year_holiday_cache = this.holiday_cache.get(date.getFullYear()); + if (!year_holiday_cache) { + return null; + } + for (const holiday of year_holiday_cache) { + if (datetime.getISODate(date) === datetime.getISODate(holiday.day)) { + //A holiday has been found. + return holiday; + } + } + if (regard_vacations) { + return this.getVacation(date); + } + return null; + } + + /** + * Checks if there is a holiday on the specified date. + * + * @param date The date to check. + * + * @param regard_vacations Whether to regard vacations in addition to holidays (true) + * or to just regard holidays (false). Defaults to false. + * + * @returns boolean True, if the specified day is a holiday / vacation day. + * Otherwise, false is returned. + */ + isHoliday(date: Date, regard_vacations: boolean = false) : boolean { + return this.getHoliday(date, regard_vacations) !== null; + } + + /** + * Returns the name of the holiday on the specified date. + * + * @param date The date for which to get the holiday name. + * + * @param regard_vacations Whether to regard vacations in addition to holidays (true) + * or to just regard holidays (false). Defaults to false. + * + * @returns string The name of the holiday / vacation, if any. + * If there is no holiday on the specified date, the string is empty. + */ + getHolidayName(date: Date, regard_vacations: boolean = false) : string { + const holiday = this.getHoliday(date, regard_vacations); + if (holiday) { + return holiday.name; + } + return ''; + } +} + + +export {Holiday, HolidayCache}; +export const holiday_cache: HolidayCache = new HolidayCache(); diff --git a/resources/assets/stylesheets/fullcalendar.scss b/resources/assets/stylesheets/fullcalendar.scss deleted file mode 100644 index 70ed244..0000000 --- a/resources/assets/stylesheets/fullcalendar.scss +++ /dev/null @@ -1,306 +0,0 @@ -@import "scss/variables"; -@import "scss/buttons"; -@import "mixins"; - -a.fc-event, -td.fc-event { - border-radius: 0; - - .fc-time { - background-color: rgba(255, 255, 255, 0.2); - font-weight: bold; - } - - .fc-title .icon { - content: ''; - display: inline-block; - vertical-align: text-bottom; - margin-right: 3px; - width: 12px; - height: 12px; - mask-size: contain; - mask-repeat: no-repeat; - mask-position: center; - background-color: currentColor; - mask-image: var(--icon-url); - } -} - -div.fc-bgevent, -td.fc-bgevent { - .title { - color: $text-color; - font-weight: bold; - text-align: center; - } - - &.holiday { - opacity: 1; - background-color: rgba($light-gray-color-60, 0.3); - - &.official { - background-color: rgba($base-gray, 0.3); - } - } -} - -.fc { - .fc-toolbar.fc-header-toolbar { - margin-bottom: 0.5em; - } - - .fc-button-group { - height: 30px; - - .fc-button { - @include button; - margin-top: 0; - margin-bottom: 0; - padding: 0; - - &:last-of-type { - margin-right: 0; - } - - .fc-icon { - /* Unset rules that are set in the fullcalendar default stylesheet: */ - line-height: unset; - height: unset; - } - } - } -} - -/* Put the year in a new line in the mobile view: */ -html.responsive-display .fc .fc-view:not(.fc-timeGridDay-view) .fc-day-header { - span.dow, span.year { - display: block; - } -} - - -.fc-button-primary:not(:disabled):active, -.fc-button-primary:not(:disabled).fc-button-active, -.fc button.fc-button.fc-state-active { - box-shadow: none; - - background-color: var(--base-color) !important; - color: var(--white); -} - -/* adjust height: */ -/* .fc-scroller.fc-time-grid-container { - height: auto !important; - min-height: 0 !important; -}*/ - -.studip-fullcalendar-header { - /* This shall look like a table caption. */ - background-color: transparent; - color: var(--base-gray); - font-size: 1.4em; - text-align: left; - - &.fullcalendar-dialog{ - width: calc(100% - 550px); - vertical-align: middle; - display: inline-block; - margin-right: 275px; - } -} - -.fullcalendar-dialogwidget-container { - border-left: 0; - display: inline-block; - flex: 0 0 auto; - margin-bottom: 1em; - position: relative; - - $width: 270px; - - padding-bottom: 7px; - width: $width; - z-index: 2; - - - .fullcalendar-dialogwidget-widget { - background: var(--white); - border: 1px solid var(--content-color-40); - margin: 15px 0 0; - } - - .fullcalendar-dialogwidget-widget-header { - @include clearfix; - background: var(--content-color-20); - color: var(--base-color); - font-weight: bold; - padding: 4px; - } - - select.fullcalendar-dialogwidget-selectlist { - overflow-y: auto; - width: 100%; - } - - .fullcalendar-dialogwidget-widget-content { - border-top: 1px solid var(--content-color-40); - padding: 4px; - transition: all 0.5s; - } -} - -.institute-plan .fc-slats tr { - height: 100px; -} - -#external-events{ - td.fc-event { - border-radius: 0; - margin: 2px 0; - background-color: var(--content-color); - border: 1px solid var(--brand-color-light); - } -} - -.institute-plan { - .fc-bg td.fc-today { - background: none; - } - - th.fc-day-header, .fc-axis, th.fc-resource-cell { - background-color: var(--content-color-10); - } -} - -.calendar-caption { - background-color: transparent; - padding-top: 0; - color: var(--base-gray); - font-size: 1.4em; - text-align: left; - margin-bottom: -10px; -} - -#event-color-picker { - display: grid; - grid-template-columns: 1fr 1fr 1fr; - column-gap: 16px; - width: 200px; - height: 204px; - margin: 10px 0 10px calc(50% - 100px); - - input { - opacity: 0; - position: absolute; - - + label { - cursor: pointer; - - &::before { - background-repeat: no-repeat; - content: ' '; - display: inline-block; - margin: 0 1px 1px 1px; - vertical-align: text-top; - background-image: none; - background-size: 100%; - height: 100%; - width: calc(100% - 4px); - } - } - - &:checked + label { - @include icon(before, checkbox-checked, $size: 100%); - &::before { - background-color: var(--color--font-inverted); - } - } - } -} - -.event-colorpicker { - background: none; - border: 0; - cursor: pointer; - padding: 0; - - width: 20px; - height: 20px; - - position: absolute; - top: 0; - right: 0; - - &.white { - @include icon(before, group4, $size: 100%); - &::before { - background-color: var(--color--font-inverted); - } - } - &.black { - @include icon(before, group4, $size: 100%); - &::before { - background-color: var(--color--font-primary); - } - } -} - -.fc[data-fullcalendar="1"].print-view { - position: absolute; - top: 0; - left: 0; - height: 2000px; - width: 2000px; - - .fc-resource-cell img, - .event-colorpicker { - display: none; - } - th span a { - color: var(--black); - } - - td.fc-today { - background: none; - } - .fc-now-indicator { - border: 0; - } - - &.without-weekend { - .fc-day-header, - .fc-day, - .fc-content-skeleton td { - &:last-child, - &:nth-last-child(2) { - display: none; - } - } - } -} - - -// Responsive -.responsive-display { - .fc-header-toolbar { - flex-direction: column; - - .fc-left, - .fc-center, - .fc-right { - width: 100%; - text-align: center; - } - .fc-left, - .fc-center { - margin-bottom: $line-height-computed - $font-size-base; - } - - } - - .fc-view-container { - .fc-day-header { - overflow: hidden; - } - } -} diff --git a/resources/assets/stylesheets/print.scss b/resources/assets/stylesheets/print.scss index fd93a75..7b2c5d8 100644 --- a/resources/assets/stylesheets/print.scss +++ b/resources/assets/stylesheets/print.scss @@ -1,8 +1,6 @@ @import "mixins"; @import "scss/breakpoints"; @import "scss/visibility"; -@import "scss/fullcalendar-print"; -@import "scss/resources-print"; /******************************************************************************* @@ -224,3 +222,7 @@ a.link-extern { font-style: oblique; } } + +.print-hidden { + display: none; +} diff --git a/resources/assets/stylesheets/scss/calendar.scss b/resources/assets/stylesheets/scss/calendar.scss index 356cb3e..480c028 100644 --- a/resources/assets/stylesheets/scss/calendar.scss +++ b/resources/assets/stylesheets/scss/calendar.scss @@ -1,10 +1,13 @@ -.fc-body { +.fc-view { .fc-event { background-color: #fff; - color: var(--color--font-primary); border-width: 1px; + .fc-event-main { + color: var(--color--font-primary); + } + &:hover { color: var(--color--font-primary); } @@ -16,10 +19,6 @@ &:hover { background-color: lighten($group-color-0, 50%); } - - .fc-time { - border-bottom: 1px solid $group-color-0; - } } &.course-color-1 { @@ -29,10 +28,6 @@ &:hover { background-color: lighten($group-color-1, 50%); } - - .fc-time { - border-bottom: 1px solid $group-color-1; - } } &.course-color-2 { @@ -42,10 +37,6 @@ &:hover { background-color: lighten($group-color-2, 50%);; } - - .fc-time { - border-bottom: 1px solid $group-color-2; - } } &.course-color-3 { @@ -55,10 +46,6 @@ &:hover { background-color: lighten($group-color-3, 50%);; } - - .fc-time { - border-bottom: 1px solid $group-color-3; - } } &.course-color-4 { @@ -68,10 +55,6 @@ &:hover { background-color: lighten($group-color-4, 50%);; } - - .fc-time { - border-bottom: 1px solid $group-color-4; - } } &.course-color-5 { @@ -81,10 +64,6 @@ &:hover { background-color: lighten($group-color-5, 50%);; } - - .fc-time { - border-bottom: 1px solid $group-color-5; - } } &.course-color-6 { @@ -94,10 +73,6 @@ &:hover { background-color: lighten($group-color-6, 50%);; } - - .fc-time { - border-bottom: 1px solid $group-color-6; - } } &.course-color-7 { @@ -107,10 +82,6 @@ &:hover { background-color: lighten($group-color-7, 50%);; } - - .fc-time { - border-bottom: 1px solid $group-color-7; - } } &.course-color-8 { @@ -120,19 +91,11 @@ &:hover { background-color: lighten($group-color-8, 50%);; } - - .fc-time { - border-bottom: 1px solid $group-color-8; - } } &.marked-course { border-color: var(--black); background-color: var(--white); - - .fc-time { - border-bottom: 1px solid var(--black); - } } &.marked-course, diff --git a/resources/assets/stylesheets/scss/fullcalendar-print.scss b/resources/assets/stylesheets/scss/fullcalendar-print.scss index 9e52dbd..c3145da 100644 --- a/resources/assets/stylesheets/scss/fullcalendar-print.scss +++ b/resources/assets/stylesheets/scss/fullcalendar-print.scss @@ -1,115 +1,74 @@ +/* + * Fullcalendar rules for printing calendars. + */ +@media print { + + .fc { + /* Disable page breaks inside the calendar: */ + page-break-inside: avoid; + + /* + * Hide the buttons: + */ + .fc-button-group { + display: none; + } -a.fc-event { - border-radius: 0; - color: var(--black); + /* + * Hide the now indicator: + */ + .fc-timegrid .fc-timegrid-now-indicator-container { + display: none; + } - .fc-time { - text-decoration: underline; - color: var(--black); - } -} + /* + * In case the calendar is smaller than the page width, + * it shall not look like there is some content missing on the right side: + */ + .fc-scrollgrid { + width: unset; + max-width: 100%; + } -div.fc-toolbar { - display: none; -} + /* + * Make sure that events are printed in black and white + * when their colours are not set explicitly: + */ + .fc-event-main, .fc-v-event { + background-color: $white; + border-color: $black; + + /* + * Set the text colour for event content to black: + */ + .event-content { + color: $black; + } -/* the "now" indicator: */ -.fc-now-indicator.fc-now-indicator-line, -.fc-now-indicator.fc-now-indicator-arrow { - display: none; -} -/* adjust height: */ -.fc-resourceTimelineDay-view { - .fc-widget-header{ - .fc-scroller { - height: 40px !important; - .fc-content > table{ - height: 26px !important; + /* + * Position icons with the text and make them black: + */ + .icons { + display: inline-block; + vertical-align: top; + color: $black; } } - } -} -.fc-resourceTimelineWeek-view { - .fc-widget-header{ - .fc-scroller { - height: 60px !important; - .fc-content > table{ - height: 26px !important; - } - } - } -} -.fc-scroller.fc-time-grid-container { - height: auto !important; - min-height: 0 !important; -} -/* disable page break for calendar: */ -div.fc-view-container { - page-break-inside: avoid; -} - -/* a potential existing map key: */ -.map-key-list { - display: none; -} - -.studip-fullcalendar-header { - &.fullcalendar-dialog{ - display: block; - width: 100%; - margin-top: 2em; - margin-right: 0; - } -} - -.fullcalendar-dialogwidget-container { - display:none !important; -} - -div.fc-timeGrid-view th.fc-axis, td.fc-axis { - display: table-cell !important; -} - -div.fc-slats, div.fc-time-grid hr { - display: block !important; -} - -section.fc th, section.fc td, section.fc hr, section.fc thead, section.fc tbody, .fc-row { - background: none !important; -} - -div.fc-content-skeleton { - & table { - height: 100% !important; - } - height: 100%; -} - -div.fc-time-grid a.fc-event { - position: absolute !important; - margin: 0 0 1px 0 !important; - - img { - filter: brightness(0); + /* + * Do not colour the current day in case someone wants to print + * with backgrounds: + */ + .fc-timegrid-col.fc-day-today { + background-color: unset; + } } -} -a.fc-timeline-event { - img { - filter: brightness(0); - } -} - -.fc-slats table tr { - line-height: 22px; -} - -.fc-resource-area { - .fc-cell-content { - a > img { - display: none; - } + /* + * Hide a potential existing map key list: + */ + .map-key-list { + display: none; } } diff --git a/resources/assets/stylesheets/scss/fullcalendar.scss b/resources/assets/stylesheets/scss/fullcalendar.scss new file mode 100644 index 0000000..c3972bc --- /dev/null +++ b/resources/assets/stylesheets/scss/fullcalendar.scss @@ -0,0 +1,341 @@ +@import "buttons"; +@import "variables"; + +.fc { + .fc-toolbar.fc-header-toolbar { + margin-bottom: 0.5em; + + /* Make sure that all the elements of the header stay inside the area of the calendar: */ + display: flex; + flex-wrap: wrap; + } + + /* Styles for the buttons above the calendar: */ + .fc-button-group { + + height: 30px; + + .fc-button.fc-button-primary { + @include button; + margin-top: 0; + margin-bottom: 0; + padding: 0; + + &.fc-button-active { + @extend .active; + } + + /* Remove the default gray "aura" around a focused button. */ + &:focus { + box-shadow: var(--brand-color-dark) 0 0 0 1px; + } + + /* The last button on the right shall align with the calendar border: */ + &:last-of-type { + margin-right: 0; + } + + /* A button with an icon as content. */ + .fc-icon { + /* Unset rules that are set in the fullcalendar default stylesheet: */ + line-height: unset; + height: unset; + } + } + } + + /* Styles for header columns (dates or days of the week) */ + + /* Column header text shall not be displayed as link: */ + @mixin calendar-header-column-content { + a { + color: var(--text-color); + + /* overwrite the default Stud.IP style for links: */ + &:visited, &:active, &:hover { + text-decoration: none; + color: var(--text-color); + } + } + } + + /* Grid view header columns: */ + table.fc-col-header thead { + background-color: var(--table-header-color); + + .fc-col-header-cell, .fc-timegrid-axis { + @include calendar-header-column-content; + } + } + + /* Timeline view header columns: */ + .fc-resource-timeline table.fc-scrollgrid-sync-table th { + background-color: var(--table-header-color); + + .fc-timeline-slot-frame { + @include calendar-header-column-content; + } + } + + .fc-timegrid { + + /* + There shall be no lines with empty labels for the minor time divisions + like half hours in a calendar where only the full hours have labels. + */ + .fc-timegrid-slot-label.fc-timegrid-slot-minor { + border-top-style: none; + } + + /* The "now indicator" shall have an orange colour: */ + .fc-timegrid-now-indicator-arrow { + border-color: var(--orange); + border-top-color: transparent; + border-bottom-color: transparent; + } + .fc-timegrid-now-indicator-line { + border-color: var(--orange); + } + } + + .fc-daygrid, .fc-timegrid { + /* + Disable the short day of week by default: + */ + .fc-col-header-cell { + .dow { + display: block; + } + + .dow-short { + display: none; + } + } + } + + /* Display the week number at the top of the cell in the top left corner: */ + &.fc-liquid-hack div.fc-timegrid-axis-frame-liquid { + position: unset; + a.fc-timegrid-axis-cushion { + padding: 2px 4px; + } + } + + + /* Styles for regular calendar events: */ + a.fc-event, + td.fc-event { + /* no round corners for events: */ + border-radius: 0; + + /* extra thick border on the left side:*/ + border-left-width: 8px; + + /* Hide overflowing text: */ + overflow: hidden; + + /* Remove the default minus character for short events: */ + .fc-timegrid-event-short .fc-event-time:after { + content: ""; + } + + /* The time or location in an event. */ + .fc-event-time, .fc-event-location { + opacity: 70%; + } + + /* The content shall span over the whole height of the event, + even if it doesn't need all the space. */ + .event-content { + height: 100%; + } + + /* The title of an event: */ + .fc-event-title { + overflow-wrap: break-word; + } + + /* The icons inside an event: */ + .fc-event-title-container { + + .action-icons { + position: absolute; + right: 0; + + button { + border: none; + background: none; + cursor: pointer; + } + } + + .icons .studip-icon { + width: $font-size-base; + height: $font-size-base; + } + } + } +} + +/* + * Special rules for the responsive view. + */ +html.responsive-display .fc { + .fc-toolbar.fc-header-toolbar { + flex-direction: column; + + .fc-toolbar-chunk { + width: 100%; + + &:not(:last-child) { + margin-bottom: $line-height-computed - $font-size-base; + } + } + } + + .fc-timegrid:not(.fc-timeGridDay-view) { + /* + Use extra lines for the day of week, day and month and the year + in the week view: + */ + .fc-day { + .dow, .year { + display: block; + } + } + } + + /* + Disable the long day of week for the month and the week view: + */ + .fc-daygrid, .fc-timegrid:not(.fc-timeGridDay-view) { + .fc-col-header-cell { + .dow { + display: none; + } + + .dow-short { + display: block; + } + } + } +} + +/* + * Special rules for calendars that display holidays: + */ +.fc.with-holidays { + .fc-timegrid { + /* + * Header cells in time grid views shall have a fixed size to avoid jumping between + * weeks with and without holidays. The latter shall also be displayed in normal font weight + * and with a smaller font. All of this shall only happen when the calendar displays holidays. + */ + .fc-col-header-cell { + height: 2.85em; + + .day { + /* The year may move into the next line of text in case that there is + * not enough space for displaying the date in one line. + */ + .year { + display: inline-block; + } + + /* In case the day of week is too long, it shall be broken into the next line to prevent overflow. */ + .dow { + word-break: break-all; + } + + /* + * Holiday names are displayed a bit smaller. The name also must not overflow. + * Instead, words shall be broken. + */ + .holiday { + font-weight: normal; + font-size: 0.75em; + word-break: break-all; + } + } + } + } +} + +/* Special styles: */ + +/* Events that are displayed in a Fullcalendar + * that is diplayed inside a Stud.IP contentbox need to have + * a padding of 0 to prevent them from being unnecessary empty. + */ +section.contentbox .fc .fc-event-main section +{ + padding: 0; +} + +/* The institute plan has larger cells than normal calendars: */ +div.fc.institute-plan { + .fc-timegrid-slot { + height: 100px; + } +} + +/* + * The room management uses special headers that shall look like + * table captions: + */ +.studip-fullcalendar-header { + background-color: transparent; + color: var(--base-gray); + font-size: 1.4em; + text-align: left; + + &.fullcalendar-dialog{ + width: calc(100% - 550px); + vertical-align: middle; + display: inline-block; + margin-right: 275px; + } +} + + +/* + * Special rules for the colour picker in the course planning view + * (admin/coursplanning/index): + */ +#event-colour-picker { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + column-gap: 16px; + width: 200px; + height: 204px; + margin: 10px 0 10px calc(50% - 100px); + + input { + opacity: 0; + position: absolute; + + + label { + cursor: pointer; + + &::before { + background-repeat: no-repeat; + content: ' '; + display: inline-block; + margin: 0 1px 1px 1px; + vertical-align: text-top; + background-image: none; + background-size: 100%; + } + } + + &:checked + label { + @include icon(before, checkbox-checked, $size: 90%); + &::before { + background-color: var(--color--font-inverted); + } + } + } +} + + +@import "fullcalendar-print"; diff --git a/resources/vue/apps/StudipCalendar.vue b/resources/vue/apps/StudipCalendar.vue new file mode 100644 index 0000000..c2689e5 --- /dev/null +++ b/resources/vue/apps/StudipCalendar.vue @@ -0,0 +1,596 @@ +<template> + <section class="studip-fullcalendar"> + <Teleport v-if="eventColourPicker" to="#sidebar-calendar-colour-picker" + name="sidebar-calendar-colour-picker"> + <section> + <p class="info-text"> + {{ $gettext('Im unteren Bereich können Sie bis zu 4 Farben frei wählen und diese via Drag & Drop auf Termine ziehen. Wenn Sie fertig sind, klicken Sie auf das Drucken-Symbol unter den Farbwählern.') }} + </p> + <div v-for="i in 4" class="colour-selector" style="background-color: #000000;" + :key="i" draggable="true" + @dragstart="startColourDragging($event)"> + <input type="color" value="#000000" class="big-colour-input"> + </div> + <StudipIcon class="text-bottom print-action" shape="print" + @click="printCalendar" + :title="$gettext('Individuelle Druckansicht drucken')"></StudipIcon> + </section> + </Teleport> + <FullCalendar :options="calendar_options" + :class="all_extra_classes" + ref="fullCalendar"> + <template v-slot:dayHeaderContent="arg"> + <div v-if="['timeGridDay', 'timeGridWeek', 'resourceTimelineWeek', 'resourceTimelineDay'].includes(arg.view.type)" + class="day"> + <div class="dow-short" v-html="getColumnDow(arg.date, true)"></div> + <div class="dow" v-html="getColumnDow(arg.date)"></div> + <template v-if="!isSemesterView(arg.view)"> + <span class="date" v-html="getColumnDate(arg.date)"></span> + <div class="holiday" v-if="isHoliday(arg.date)"> + {{ getHolidayName(arg.date) }} + </div> + </template> + </div> + <template v-if="arg.view.type === 'dayGridMonth'"> + <div class="dow-short" v-html="getColumnDow(arg.date, true)"></div> + <div class="dow" v-html="getColumnDow(arg.date)"></div> + </template> + </template> + <template v-slot:eventContent="arg"> + <section v-if="arg.event.display === 'auto' && ['timeGridDay', 'timeGridWeek'].includes(arg.view.type)" + :title="arg.event.title" class="event-content" + @drop="handleElementDropOnEvent($event)" + @dragenter.prevent @dragover.prevent> + <div class="fc-event-title-container"> + <span v-if="arg.event.extendedProps['icons']" class="icons"> + <StudipIcon v-for="icon of arg.event.extendedProps['icons']" + :shape="icon" v-bind:key="icon" + :style="{color: arg.event.textColor}" + class="text-bottom"></StudipIcon> + </span> + <div v-if="arg.event.extendedProps['action-icons']" + class="action-icons" ref="action_icons"> + <span v-for="[key, action] of Object.entries(arg.event.extendedProps['action-icons'] as Array<Action>)" + v-bind:key="key"> + <button :title="action.label" + @click.stop="openActionIconUrlAsDialog(action)"> + <StudipIcon v-if="action.icon_name" + :shape="action.icon_name" + :style="{color: arg.event.textColor}" + class="text-bottom"></StudipIcon> + </button> + </span> + </div> + <span v-if="arg.event.title" class="fc-event-title"> + {{ arg.event.title }} + </span> + <span v-if="arg.event.extendedProps['title-lines']" + class="fc-event-title"> + <div v-for="[key, line] of Object.entries(arg.event.extendedProps['title-lines'])" + v-bind:key="key"> + {{ line }} + </div> + </span> + </div> + <div v-if="!arg.event.allDay" class="fc-event-time"> + {{ arg.timeText }} + </div> + </section> + <div v-if="arg.event.display === 'auto' && ['dayGridMonth', 'resourceTimelineWeek', 'resourceTimelineDay'].includes(arg.view.type)" + :style="{color: arg.event.textColor, backgroundColor: arg.event.backgroundColor, borderColor: arg.event.borderColor, width: '100%'}" + :title="arg.event.title"> + <span v-if="['dayGridMonth', 'dayGridYear'].includes(arg.view.type)" + class="fc-event-time"> + {{ arg.timeText }} + </span> + <span class="fc-event-title-container"> + <span v-if="arg.event.extendedProps['icons']" class="icons"> + <StudipIcon v-for="icon of arg.event.extendedProps['icons']" :shape="icon" v-bind:key="icon" + class="text-bottom"></StudipIcon> + </span> + <span class="fc-event-title"> + {{ arg.event.title }} + </span> + </span> + </div> + <div v-if="arg.event.display === 'background'" + :title="arg.event.title"> + <div v-if="arg.event.extendedProps['generate-title']" + class="title"> + {{ arg.event.title }} + </div> + </div> + </template> + </FullCalendar> + </section> +</template> +<script lang="ts"> +import {defineComponent} from 'vue'; +import FullCalendar from "@fullcalendar/vue3"; +import { + CalendarOptions, CalendarApi, + DateSelectionApi, DatesSetArg, + EventClickArg, EventDropArg, EventInput, ViewApi, +} from '@fullcalendar/core'; +import {Draggable, EventReceiveArg, EventResizeDoneArg} from '@fullcalendar/interaction'; + +import {Action} from "../../assets/javascripts/lib/action"; +import {holiday_cache} from "../../assets/javascripts/lib/holiday"; +import {datetime} from "../../assets/javascripts/lib/datetime"; +import Dialog from "../../assets/javascripts/lib/dialog.js"; +import StudipIcon from "../components/StudipIcon.vue"; +import {EventURLParameters, StudipCalendarConfig} from "../../assets/javascripts/lib/calendar"; + +export default defineComponent({ + name: "StudipCalendar", + computed: { + Dialog() { + return Dialog + } + }, + components: { + StudipIcon, + FullCalendar + }, + props: { + config: { + type: Object, + required: true, + default: () => ({}) + }, + actionUrls: { + type: Object, + required: false, + default: () => ({}) + }, + dialogSize: { + type: String, + required: false, + default: 'auto' + }, + customEventHandlers: { + type: Object, + required: false, + default: () => ({}) + }, + displayHolidays: { + type: Boolean, + required: false, + default: true + }, + displayVacations: { + type: Boolean, + required: false, + default: true + }, + extraClasses: { + type: String, + required: false, + default: '' + }, + externalDroppableContainerId: { + type: String, + required: false, + default: '' + }, + externalDroppableEventSelector: { + type: String, + required: false, + default: '' + }, + eventColourPicker: { + type: Boolean, + required: false, + default: false + } + }, + emits: { + eventDropped: (payload: EventDropArg) => payload, + eventReceived: (payload: EventReceiveArg) => payload, + eventResized: (payload: EventResizeDoneArg) => payload + }, + setup() { + const printCalendar = () => { + window.print(); + }; + return {printCalendar}; + }, + data() { + //Make sure that defaults are set for the calendar: + const full_config = new StudipCalendarConfig(this.config); + //Convert the configuration to an object that can be passed + //to Fullcalendar later: + const calendar_options = full_config.getConfig(); + const all_extra_classes : Array<string> = []; + if (this.extraClasses.length > 0) { + all_extra_classes.push(this.extraClasses); + } + if (this.displayVacations || this.displayHolidays) { + all_extra_classes.push('with-holidays'); + } + + //Check if the responsive-design class is present in the HTML DOM node. + //If so, start in the day view (if present) instead of the week view (if present). + if (calendar_options.views !== undefined + && calendar_options.views.timeGridDay !== undefined + && calendar_options.views.timeGridWeek !== undefined + && calendar_options.initialView === 'timeGridWeek') { + const nodes = document.getElementsByTagName('html'); + if (nodes.length >= 1) { + //Regard only the first node. + const html_node = nodes[0]; + if (html_node.classList.contains('responsive-display')) { + //Start in day view: + calendar_options.initialView = 'timeGridDay'; + } + } + } + + //Now the event handlers for this component are set: + const event_handlers = { + datesSet: this.handleCalendarRangeUpdate, + eventDrop: calendar_options.editable ? this.handleEventDrop : undefined, + eventResize: calendar_options.editable ? this.handleEventResize : undefined, + select: calendar_options.selectable ? this.handleSelection : undefined, + eventClick: this.handleEventClick, + eventReceive: this.handleEventReceive + } as CalendarOptions; + + //Return the calendar options with other data: + return { + calendar_api: null as CalendarApi|null, + calendar_options: {...calendar_options, ...event_handlers}, + all_extra_classes: all_extra_classes.join(' ') + } + }, + mounted() { + const calendar = this.$refs.fullCalendar as typeof FullCalendar; + if (calendar) { + this.calendar_api = calendar.getApi(); + } + this.initExternalDraggableItems(); + if (this.$refs.action_icons) { + const element = this.$refs.action_icons as HTMLDivElement; + element.addEventListener<"click">('click', function(event: Event) { + event.preventDefault(); + }); + } + //Check if there is a date selector with calendar control enabled. + //In that case, the calendar shall change its date when the + //date selector changes its value. + const date_picker = document.querySelector('#date_select[data-calendar-control]') as HTMLElement; + if (date_picker) { + date_picker.onchange = this.useDateFromDatePicker; + } + }, + methods: { + openActionIconUrlAsDialog(action: Action) { + if (action.url) { + Dialog.fromURL(action.url, {size: 'auto'}); + } + }, + handleElementDropOnEvent(event: DragEvent) { + if (!event.dataTransfer || !event.target) { + return; + } + const drop_target = event.target as HTMLElement; + const colour = event.dataTransfer.getData('colour'); + if (colour && drop_target) { + //Colour the drop target: + drop_target.style.backgroundColor = colour; + //Colour the surrounding .fc-event element (for the border): + const fc_event = drop_target.closest('.fc-event') as HTMLElement; + if (fc_event) { + fc_event.style.backgroundColor = colour; + fc_event.style.borderColor = colour; + } + } + }, + useDateFromDatePicker(event: Event) { + if (!event.target || !this.calendar_api) { + //Nothing to do. + return; + } + const target = event.target as HTMLInputElement; + if (!target) { + //Still nothing to do. + return; + } + if (!target.value) { + //Positively still nothing to do. + return; + } + const date_str = target.value; + //The date format should be in the format dd.mm.YYYY. + //But d.m.YYYY could also be acceptable. + if (date_str.length < 8) { + //Strange unsupported date format. + return; + } + //The date string needs to be split into three parts and then used + //as date parts for a Date object. + const date_parts = date_str.split('.'); + if (date_parts.length != 3) { + //Invalid date format. + return; + } + const date = new Date(); + date.setFullYear(parseInt(date_parts[2])); + date.setMonth(parseInt(date_parts[1]) - 1); + date.setDate(parseInt(date_parts[0])); + date.setHours(12); + date.setMinutes(0); + date.setSeconds(0); + this.calendar_api.gotoDate(date); + }, + startColourDragging(event: DragEvent) { + if (!event.dataTransfer || !event.target) { + return; + } + const colour_el = event.target as HTMLElement; + if (!colour_el.style.backgroundColor) { + return; + } + event.dataTransfer.dropEffect = 'move'; + event.dataTransfer.effectAllowed = 'move'; + event.dataTransfer.setData('colour', colour_el.style.backgroundColor); + }, + initExternalDraggableItems() { + if (this.externalDroppableContainerId.length > 0 + && this.externalDroppableEventSelector.length > 0) { + const container = document.getElementById(this.externalDroppableContainerId); + if (container) { + new Draggable( + container, + { + itemSelector: this.externalDroppableEventSelector, + eventData: function (event_element) { + const event_attr = event_element.getAttribute('data-event'); + if (event_attr) { + return JSON.parse(event_attr); + } + return null; + } + } + ); + } + } + }, + getColumnDate(date: Date) { + return datetime.getStudipDate(date, false, true, true); + }, + getColumnDow(date: Date, short: boolean = false) { + return datetime.getDayOfWeekName(date.getDay(), short); + }, + isSemesterView(view: ViewApi) { + if (['dayGridMonth', 'resourceTimelineWeek', 'resourceTimelineDay'].includes(view.type)) { + //These views do not exist in semester views. + return false; + } + const day_header_format = view.getOption('dayHeaderFormat'); + if (day_header_format === undefined) { + //No day header format defined. Assume a semester view: + return true; + } + if (day_header_format.standardDateProps.year && day_header_format.standardDateProps.month && day_header_format.standardDateProps.day) { + //Year, month and day shall be displayed. This is not a semester view. + return false; + } + //In all other cases, assume a semester view: + return true; + }, + isHoliday(date: Date) { + if (date === undefined) { + return false; + } + if (!this.displayHolidays && !this.displayVacations) { + //Holidays are not displayed at all. + return false; + } + if (this.displayHolidays) { + return holiday_cache.isHoliday(date, this.displayVacations); + } else { + return holiday_cache.isVacation(date); + } + }, + getHolidayName(date: Date) { + if (date === undefined) { + return ''; + } + if (!this.displayHolidays && !this.displayVacations) { + //Holidays are not displayed at all. + return ''; + } + if (this.displayHolidays) { + return holiday_cache.getHolidayName(date, this.displayVacations); + } else { + return holiday_cache.getVacationName(date); + } + }, + handleCalendarRangeUpdate: function(arg: DatesSetArg) : void { + if (this.displayHolidays || this.displayVacations) { + //Make sure that all holidays and vacations are loaded for the range. + //NOTE: This works only for views that span over a maximum of two years + //like week and month views at the start/end of a year. + if (this.displayHolidays) { + holiday_cache.loadHolidays(arg.view.activeStart.getFullYear()); + holiday_cache.loadHolidays(arg.view.activeEnd.getFullYear()); + } + if (this.displayVacations) { + holiday_cache.loadVacations(arg.view.activeStart.getFullYear()); + holiday_cache.loadVacations(arg.view.activeEnd.getFullYear()); + } + } + if (this.isSemesterView(arg.view)) { + //Remove the navigation button if the view is not timeGridDay: + const end_nav = document.querySelector(':nth-last-child(1 of .fc-toolbar-chunk)') as HTMLElement; + if (end_nav) { + if (arg.view.type === 'timeGridDay') { + //Show the navigation buttons. + end_nav.style.display = 'initial'; + } else { + //Hide the navigation buttons. + end_nav.style.display = 'none'; + } + } + } + }, + handleSelection: function(selection: DateSelectionApi) { + if (!this.calendar_options.editable || this.actionUrls.length < 1) { + //The calendar isn't editable. + return; + } + const data = new EventURLParameters(); + data.start = selection.start; + data.end = selection.end; + data.all_day = selection.allDay; + if (selection.resource) { + data.setResourceId(selection.resource.id); + } + if (this.actionUrls['add']) { + //Add the selected time range to the URL and load it + //in a dialog: + Dialog.fromURL( + this.actionUrls['add'], + { + data: data.toObject(), + size: this.dialogSize + } + ); + } + }, + handleEventClick: function(event_data: EventClickArg) { + if (!event_data.event.extendedProps.studip_view_urls + || !event_data.event.extendedProps.studip_view_urls.show) { + //Nothing to do. + return; + } + //Load the dialog: + Dialog.fromURL( + event_data.event.extendedProps.studip_view_urls.show, + { + size: this.dialogSize + } + ); + }, + handleEventDrop: function(drop_arg: EventDropArg) { + if (!this.calendar_options.editable || !drop_arg.event.startEditable + || !drop_arg.event.start || + (drop_arg.oldEvent.allDay === drop_arg.event.allDay && !drop_arg.event.end)) { + //Nothing to do. + return; + } + const data = new EventURLParameters(drop_arg.event); + if (data.start === null) { + //Something went wrong. We cannot continue. + return; + } + if (drop_arg.oldEvent.allDay && !drop_arg.event.allDay) { + //An all-day event has become a date with a time range. + //Construct an end date for it 1 hour after the start. + data.setEnd(new Date(data.start.getTime() + 3600000)); + //Set the end to the event object, too so that it can be dragged some more: + drop_arg.event.setEnd(data.end); + } + if (drop_arg.newResource) { + data.setResourceId(drop_arg.newResource.id); + } + if (drop_arg.event.extendedProps.studip_api_urls.move) { + //Call the move URL as HTTP POST: + $.post({ + async: false, + url: drop_arg.event.extendedProps.studip_api_urls.move, + data: data.toObject() + }) + .fail(drop_arg.revert) + .done(() => { + //Reload all calendar events so that their + //move-URLs are also updated. + drop_arg.view.calendar.refetchEvents(); + }); + } else if (drop_arg.event.extendedProps.studip_view_urls.move_dialog) { + //Show the move dialog: + Dialog.fromURL( + drop_arg.event.extendedProps.studip_view_urls.move_dialog, + { + data: data.toObject(), + size: this.dialogSize + } + ); + } + this.$emit('eventDropped', drop_arg as EventDropArg); + }, + handleEventResize: function(resize_arg: EventResizeDoneArg) { + if (!this.calendar_options.editable || !resize_arg.event.startEditable + || !resize_arg.event.start || !resize_arg.event.end) { + //Nothing to do. + return; + } + const data = new EventURLParameters(resize_arg.event); + if (resize_arg.event.extendedProps.studip_api_urls.resize) { + //Call the move URL as HTTP POST: + $.post({ + async: false, + url: resize_arg.event.extendedProps.studip_api_urls.resize, + data: data.toObject() + }) + .fail(resize_arg.revert) + .done(() => { + //Reload all calendar events so that their + //resize-URLs are also updated. + resize_arg.view.calendar.refetchEvents(); + }); + } else if (resize_arg.event.extendedProps.studip_view_urls.resize_dialog) { + Dialog.fromURL( + resize_arg.event.extendedProps.studip_view_urls.resize_dialog, + { + data: data.toObject(), + size: this.dialogSize + } + ); + } + this.$emit('eventResized', resize_arg as EventResizeDoneArg); + }, + handleEventReceive(receive_arg: EventReceiveArg) { + if (!receive_arg.event || !receive_arg.event.start || !receive_arg.event.end) { + //Nothing to do except of reverting the event: + receive_arg.revert(); + return; + } + const data = new EventURLParameters(); + data.setStart(receive_arg.event.start); + data.setEnd(receive_arg.event.end); + data.setAllDay(receive_arg.event.allDay); + if (receive_arg.event.extendedProps.studip_api_urls.receive) { + $.post({ + async: false, + url: receive_arg.event.extendedProps.studip_api_urls.receive, + data: data.toObject() + }) + .fail(receive_arg.revert) + .done(data => { + //Add the event that has been created and remove the + //temporary event from the drop. + const event_data = JSON.parse(data); + if (event_data) { + receive_arg.view.calendar.addEvent(event_data as EventInput); + receive_arg.event.remove(); + } + }); + } else if (receive_arg.event.extendedProps.studip_view_urls.receive_dialog) { + Dialog.fromURL( + receive_arg.event.extendedProps.studip_view_urls.receive_dialog, + { + data: data.toObject(), + size: this.dialogSize + } + ); + } + this.$emit('eventReceived', receive_arg); + } + } +}) +</script> +<style lang="scss"> +@import '../../assets/stylesheets/scss/fullcalendar'; +</style> diff --git a/resources/vue/components/StudipDateTime.vue b/resources/vue/components/StudipDateTime.vue index 4f4ab72..0bed4cf 100644 --- a/resources/vue/components/StudipDateTime.vue +++ b/resources/vue/components/StudipDateTime.vue @@ -1,5 +1,6 @@ <script setup> import { ref, computed, onMounted } from "vue" +import {datetime} from "../../assets/javascripts/lib/datetime"; const props = defineProps({ timestamp: { @@ -32,7 +33,7 @@ const date = computed(() => { return null }) -const datetime = computed(() => (date.value ? date.value.toISOString() : '')) +const current_datetime = computed(() => (date.value ? date.value.toISOString() : '')) const displayRelative = () => { if (!date.value || !props.relative) { @@ -47,7 +48,7 @@ const formattedDate = (forceAbsolute = false) => { return 'Invalid date' } const relativeValue = !forceAbsolute && props.relative && displayRelative() - return STUDIP.DateTime.getStudipDate(date.value, relativeValue, props.date_only) + return datetime.getStudipDate(date.value, relativeValue, props.date_only) } onMounted(() => { @@ -58,7 +59,7 @@ onMounted(() => { </script> <template> - <time :datetime="datetime" v-if="date" :title="title"> + <time :datetime="current_datetime" v-if="date" :title="title"> {{ formattedDate() }} </time> </template> diff --git a/resources/vue/components/form_inputs/DateListInput.vue b/resources/vue/components/form_inputs/DateListInput.vue index c2ad1ac..72efab2 100644 --- a/resources/vue/components/form_inputs/DateListInput.vue +++ b/resources/vue/components/form_inputs/DateListInput.vue @@ -22,6 +22,7 @@ <script> import StudipDateTime from "../StudipDateTime.vue"; +import {datetime} from "../../../assets/javascripts/lib/datetime"; export default { name: "date-list-input", @@ -40,7 +41,7 @@ export default { }, data () { return { - selected_date_value: STUDIP.DateTime.getStudipDate(new Date(), false, true), + selected_date_value: datetime.getStudipDate(new Date(), false, true), selected_date_list: this.selected_dates.map(date => new Date(date)), input_name: this.name, }; @@ -85,11 +86,11 @@ export default { this.$refs.list_message_field.innerText = this.$gettext( 'Datum %{date} entfernt', - { date: STUDIP.DateTime.getStudipDate(date, false, true) } + { date: datetime.getStudipDate(date, false, true) } ); }, getISODate(date) { - return STUDIP.DateTime.getISODate(date); + return datetime.getISODate(date); } } } |
