aboutsummaryrefslogtreecommitdiff
path: root/resources/vue/apps/StudipCalendar.vue
diff options
context:
space:
mode:
Diffstat (limited to 'resources/vue/apps/StudipCalendar.vue')
-rw-r--r--resources/vue/apps/StudipCalendar.vue596
1 files changed, 596 insertions, 0 deletions
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>