/* * This file is part of the Kimai time-tracking app. * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ /*! * [KIMAI] KimaiCalendar: wrapping Fullcalendar.io */ import { Popover } from 'bootstrap'; import { Calendar } from '@fullcalendar/core'; import dayGridPlugin from '@fullcalendar/daygrid'; import timeGridPlugin from '@fullcalendar/timegrid'; import bootstrap5Plugin, { BootstrapTheme } from '@fullcalendar/bootstrap5'; import googlePlugin from '@fullcalendar/google-calendar'; import iCalendarPlugin from '@fullcalendar/icalendar'; import interactionPlugin, { Draggable } from '@fullcalendar/interaction'; import arLocale from '@fullcalendar/core/locales/ar'; import csLocale from '@fullcalendar/core/locales/cs'; import daLocale from '@fullcalendar/core/locales/da'; import deLocale from '@fullcalendar/core/locales/de'; import deAtLocale from '@fullcalendar/core/locales/de-at'; import elLocale from '@fullcalendar/core/locales/el'; import esLocale from '@fullcalendar/core/locales/es'; import euLocale from '@fullcalendar/core/locales/eu'; import faLocale from '@fullcalendar/core/locales/fa'; import fiLocale from '@fullcalendar/core/locales/fi'; import frLocale from '@fullcalendar/core/locales/fr'; import heLocale from '@fullcalendar/core/locales/he'; import hrLocale from '@fullcalendar/core/locales/hr'; import huLocale from '@fullcalendar/core/locales/hu'; import itLocale from '@fullcalendar/core/locales/it'; import jaLocale from '@fullcalendar/core/locales/ja'; import koLocale from '@fullcalendar/core/locales/ko'; import nbLocale from '@fullcalendar/core/locales/nb'; import nlLocale from '@fullcalendar/core/locales/nl'; import plLocale from '@fullcalendar/core/locales/pl'; import ptLocale from '@fullcalendar/core/locales/pt'; import ptBrLocale from '@fullcalendar/core/locales/pt-br'; import roLocale from '@fullcalendar/core/locales/ro'; import ruLocale from '@fullcalendar/core/locales/ru'; import skLocale from '@fullcalendar/core/locales/sk'; import svLocale from '@fullcalendar/core/locales/sv'; import trLocale from '@fullcalendar/core/locales/tr'; import zhLocale from '@fullcalendar/core/locales/zh-cn'; import viLocale from '@fullcalendar/core/locales/vi'; import enGbLocale from '@fullcalendar/core/locales/en-gb'; import enUsLocale from '@fullcalendar/core/locales/en-gb'; import KimaiColor from './KimaiColor'; import KimaiContextMenu from "./KimaiContextMenu"; import { DateTime } from 'luxon'; export default class KimaiCalendar { /** * Options is a huge JSON object. * * @param {KimaiContainer} kimai * @param {HTMLElement} element * @param {Object} options */ constructor(kimai, element, options) { this.kimai = kimai; this.options = options; /** @type {KimaiAPI} API */ const API = this.kimai.getPlugin('api'); /** @type {KimaiDateUtils} DATES */ const DATES = this.kimai.getPlugin('date'); /** @type {KimaiAjaxModalForm} MODAL */ const MODAL = this.kimai.getPlugin('modal'); // Instead of using "buttonIcons" the theme needs to be adjusted directly // https://fullcalendar.io/docs/buttonIcons BootstrapTheme.prototype.classes = { root: 'fc-theme-bootstrap5', tableCellShaded: 'fc-theme-bootstrap5-shaded', buttonGroup: 'btn-group', button: 'btn btn-primary btn-icon', // required for Tabler buttonActive: 'active', popover: 'popover', popoverHeader: 'popover-header', popoverContent: 'popover-body', }; BootstrapTheme.prototype.baseIconClass = ''; // required for Fontawesome BootstrapTheme.prototype.iconOverridePrefix = ''; // required for Fontawesome BootstrapTheme.prototype.iconClasses = { close: 'fa-times', prev: this.options['icons']['previous'], next: this.options['icons']['next'], prevYear: this.options['icons']['previousYear'], nextYear: this.options['icons']['nextYear'], }; BootstrapTheme.prototype.rtlIconClasses = { prev: this.options['icons']['previous'], next: this.options['icons']['next'], prevYear: this.options['icons']['previousYear'], nextYear: this.options['icons']['nextYear'], }; let calendarOptions = { locales: [ enGbLocale, enUsLocale, arLocale, csLocale, daLocale, deLocale, deAtLocale, elLocale, esLocale, euLocale, faLocale, fiLocale, frLocale, heLocale, hrLocale, huLocale, itLocale, jaLocale, koLocale, nbLocale, nlLocale, plLocale, ptLocale, ptBrLocale, roLocale, ruLocale, skLocale, svLocale, trLocale, zhLocale, viLocale ], plugins: [ bootstrap5Plugin, dayGridPlugin, timeGridPlugin, googlePlugin, iCalendarPlugin, interactionPlugin ], initialView: this.toInternalViewName(this.options['initialView']), initialDate: this.options['initialDate'], // https://fullcalendar.io/docs/theming themeSystem: 'bootstrap5', // https://fullcalendar.io/docs/headerToolbar headerToolbar: { start: 'title', center: 'dayGridMonth,timeGridWeek,timeGridDay', end: 'today prev,next' }, direction: this.kimai.getConfiguration().get('direction'), locale: this.kimai.getConfiguration().getLanguage().toLowerCase(), // https://fullcalendar.io/docs/height // auto makes the calendar too small // height: 'auto', height: '80vh', // allow clicking e.g. week-numbers to change the view to this week navLinks: true, nowIndicator: true, weekends: this.options['showWeekends'], weekNumbers: this.options['showWeekNumbers'], weekNumberCalculation: 'ISO', firstDay: this.kimai.getConfiguration().getFirstDayOfWeek(true), now: this.options['now'], businessHours: { daysOfWeek: [0, 1, 2, 3, 4, 5, 6], startTime: this.options['businessTimeBegin'], endTime: this.options['businessTimeEnd'] }, slotDuration: this.options['slotDuration'], slotMinTime: this.options['timeframeBegin'] + ':00', slotMaxTime: this.options['timeframeEnd'] === '23:59' ? '24:00:00' : (this.options['timeframeEnd'] + ':59'), // deactivate for auto calculation, which does a good job. // but 1h seems to be a "normal distance" for calendar apps (like Google and Apple) slotLabelInterval: '1:00', // how long should entries look like when they don't have an end defaultTimedEventDuration: this.options['slotDuration'], // https://fullcalendar.io/docs/timeZone timeZone: this.options['timezone'], // TODO implement me later on // https://fullcalendar.io/docs/validRange // limit to the users registration date or a configuration for the first day in job // https://fullcalendar.io/docs/hiddenDays // once we can configure working days // hiddenDays: [ 2, 4 ] // dropAccept dayMaxEventRows: true, eventMaxStack: this.options['dayLimit'], dayMaxEvents: this.options['dayLimit'], // the callbacks "viewDidMount" and "viewWillUnmount" are only called when switching between month and others, not between week and day datesSet: (dateInfo) => { document.dispatchEvent(new CustomEvent('kimai.calendar.changeDate', {detail: { view: this.toExternalViewName(dateInfo.view.type), date: dateInfo.start.toISOString().split('T')[0], }})); }, views: { dayGrid: { dayMaxEventRows: this.options['dayLimit'] } }, // ============= POPOVER ============= viewClassNames: () => { document.querySelector('.fc-dayGridMonth-button').classList.remove('btn-icon'); document.querySelector('.fc-timeGridWeek-button').classList.remove('btn-icon'); document.querySelector('.fc-timeGridDay-button').classList.remove('btn-icon'); }, // DESTROY TO PREVENT MEMORY LEAKS eventWillUnmount: (unmountInfo) => { // this happens when a user drags an external event to the calendar (view: week and day) and moves it around // for some reason the "eventWillUnmount" is triggered for this "potential but not yet existing event" if (unmountInfo.event.source === null) { return; } if (!this.isKimaiSource(unmountInfo.event)) { return; } const popover = Popover.getInstance(unmountInfo.el); if (popover !== null) { popover.dispose(); } }, // SHOW POPOVER FOR TIMESHEETS eventMouseEnter: (mouseEnterInfo) => { const event = mouseEnterInfo.event; if (!this.isKimaiSource(event)) { // TODO allow to copy into kimai return; } const element = mouseEnterInfo.el; const popoverTitle = DATES.getFormattedDate(event.start) + ' | ' + DATES.formatTime(event.start) + ' - ' + (event.end ? DATES.formatTime(event.end) : ''); const popoverContent = this.renderEventPopoverContent(event); let popover = Popover.getInstance(element); if (popover !== null) { // see https://github.com/kimai/kimai/issues/4043 popover.setContent({ '.popover-header': popoverTitle, '.popover-body': popoverContent }); } else { // https://getbootstrap.com/docs/5.0/components/popovers/#options popover = new Popover(element, { title: popoverTitle, placement: 'top', html: true, content: popoverContent, trigger: 'focus', }); } popover.show(); }, // HIDE POPOVER eventMouseLeave: (mouseLeaveInfo) => { if (!this.isKimaiSource(mouseLeaveInfo.event)) { return; } this.hidePopover(mouseLeaveInfo.el); }, // ContextMenu eventDidMount: (arg) => { arg.el.addEventListener('contextmenu', (jsEvent) => { jsEvent.preventDefault(); const event = arg.event; if (!event.allDay) { const url = this.options.url.actions(event.extendedProps.timesheet); API.get(url, {}, result => { const contextMenu = new KimaiContextMenu('calendar_contextMenu'); contextMenu.createFromApi(jsEvent, result); }, (e) => { console.log('Failed to load actions for context menu', e); }); } }); }, // called after all events of one source were set, so this can // and will be called multiple times before the calendar is initialized eventsSet: (events) => { this._renderDayAndWeekSum(this.getCalendar().getCurrentData().viewSpec.type, events); } }; // ============= DRAG & DROP ============= if (!this.hasPermission('punch') && this.hasPermission('create') && this.options.dragdrop !== undefined) { const draggableList = [].slice.call(document.querySelectorAll(this.options.dragdrop.container)); draggableList.map((containerEl) => { return new Draggable(containerEl, { itemSelector: this.options.dragdrop.items }); }); calendarOptions = {...calendarOptions, ...{ droppable: true, // drop function handles external draggable events drop: (dropInfo) => { document.dispatchEvent(new CustomEvent('kimai.reloadContent')); const entry = dropInfo.draggedEl; const source = entry.parentElement; let data = JSON.parse(entry.dataset.entry); const urlReplacer = JSON.parse(source.dataset.routeReplacer); let apiUrl = source.dataset.route; for (const [key, value] of Object.entries(urlReplacer)) { apiUrl = apiUrl.replace(key, data[value]); } let begin = dropInfo.date; if (dropInfo.view.type === 'dayGridMonth') { let defaultStartTime = this.options.defaultStartTime; if (defaultStartTime === null) { const now = new Date(); defaultStartTime = (now.getHours() < 10 ? '0' : '') + now.getHours() + ':' + (now.getMinutes() < 10 ? '0' : '') + now.getMinutes(); } begin = DATES.addHumanDuration(begin, defaultStartTime); } let end = DATES.addHumanDuration(begin, this.options['slotDuration']); if (!this.hasPermission('punch')) { if (this.hasPermission('edit_begin')) { data.begin = DATES.formatForAPI(begin); } if (this.hasPermission('edit_end')) { data.end = DATES.formatForAPI(end); } } data = this.options.preparePayloadForUpdate(data); if (source.dataset.method === 'PATCH') { API.patch( apiUrl, JSON.stringify(data), (result) => { const newItem = this.convertSourceForCalendar(result); this.getCalendar().addEvent(newItem, true); document.dispatchEvent(new CustomEvent('kimai.reloadedContent')); } ); } else { API.post( apiUrl, JSON.stringify(data), (result) => { const newItem = this.convertSourceForCalendar(result); this.getCalendar().addEvent(newItem, true); document.dispatchEvent(new CustomEvent('kimai.reloadedContent')); } ); } }, }}; } // ============= CREATE NEW RECORDS ============= // After click or selection, not allowed for everyone if (!this.hasPermission('punch') && this.hasPermission('create')) { calendarOptions = {...calendarOptions, ...{ dateClick: (dateClickInfo) => { // Day-clicks are always triggered, unless a selection was created. // So clicking in a day (month view) or any slot (week and day view) will trigger a dayClick // BEFORE triggering a select - make sure not two create dialogs are requested if (dateClickInfo.view.type !== 'dayGridMonth') { return; } const createUrl = this.options.url.create(dateClickInfo.dateStr); MODAL.openUrlInModal(createUrl); }, selectable: true, select: (selectionInfo) => { if(selectionInfo.view.type === 'dayGridMonth') { // Multi-day clicks are NOT allowed in the month view, as simple day clicks would also trigger // a select - there is no way to distinguish a simple click and a two-day selection return; } const createUrl = this.options.url.create(selectionInfo.startStr, selectionInfo.endStr); MODAL.openUrlInModal(createUrl); }, }}; } // ============= EDIT TIMESHEET ============= if (this.hasPermission('edit')) { calendarOptions = {...calendarOptions, ...{ eventClick: (eventClickInfo) => { const event = eventClickInfo.event; if (!this.isKimaiSource(event)) { eventClickInfo.jsEvent.preventDefault(); return; } this.hidePopover(eventClickInfo.el); if (!event.extendedProps.exported || this.hasPermission('edit_exported')) { MODAL.openUrlInModal( this.options.url.edit(event.id), (reason) => { // 403 = user is not allowed to edit the entry (e.g. lockdown mode) if (reason.status !== 403) { // keep the log, it might help with debugging console.log(reason); } } ); } }, }}; // UPDATE TIMESHEET - MOVE THEM OR EXTEND THEM if (!this.hasPermission('punch')) { calendarOptions = {...calendarOptions, ...{ // https://fullcalendar.io/docs/event-dragging-resizing dragRevertDuration: 0, eventStartEditable: this.hasPermission('edit_begin'), eventDurationEditable: this.hasPermission('edit_end') || this.hasPermission('edit_duration'), eventDragStart: (info) => { this.hidePopover(info.el); }, eventDrop: (eventDropInfo) => { this.changeHandler(eventDropInfo); }, eventResizeStart: (info) => { this.hidePopover(info.el); }, eventResize: (eventResizeInfo) => { this.changeHandler(eventResizeInfo); }, }}; } } // ============= GOOGLE CALENDAR ============= if (this.options['googleCalendarApiKey'] !== undefined) { calendarOptions = {...calendarOptions, ...{ // https://fullcalendar.io/docs/google-calendar googleCalendarApiKey: this.options['googleCalendarApiKey'], }}; } // ============= EVENT SOURCES ============= let eventSources = []; for (const source of this.options['eventSources']) { let calendarSource = {}; if (source.type === 'timesheet') { calendarSource = {...calendarSource, ...{ id: 'kimai-' + source.id, events: (fetchInfo, successCallback, failureCallback) => { const targetFrom = DATES.formatForAPI(fetchInfo.start); const targetTo = DATES.formatForAPI(fetchInfo.end); let url = source.url; url = url.replace('{from}', targetFrom); url = url.replace('__FROM__', targetFrom); url = url.replace('{to}', targetTo); url = url.replace('__TO__', targetTo); API.get(url, {}, result => { let apiEvents = []; for (const record of result) { apiEvents.push(this.convertSourceForCalendar(record)); } successCallback(apiEvents); }, failureCallback); }, }}; } else if (source.type === 'google') { calendarSource = {...calendarSource, ...{ id: 'google-' + source.id, name: 'google', editable: false, }}; } else if (source.type === 'json') { calendarSource = {...calendarSource, ...{ id: 'json-' + source.id, editable: false, events: (fetchInfo, successCallback, failureCallback) => { const targetFrom = DATES.formatForAPI(fetchInfo.start); const targetTo = DATES.formatForAPI(fetchInfo.end); let url = source.url; url = url.replace('{from}', targetFrom); url = url.replace('__FROM__', targetFrom); url = url.replace('{to}', targetTo); url = url.replace('__TO__', targetTo); API.get(url, {}, result => { let apiEvents = []; for (const record of result) { apiEvents.push(record); } successCallback(apiEvents); }, failureCallback); }, }}; } else if (source.type === 'ical') { calendarSource = {...calendarSource, ...{ id: 'ical-' + source.id, url: source.url, format: 'ics', editable: false, }}; } else { console.log('Unknown source type given, skipping to load events from: ' + source.id); continue; } if (source.options !== undefined) { calendarSource = {...calendarSource, ...source.options}; } eventSources.push(calendarSource); } if (eventSources.length > 0) { calendarOptions = {...calendarOptions, ...{ eventSources: eventSources, }}; } // INITIALIZE CALENDAR this.calendar = new Calendar(element, calendarOptions); } /** * @param {EventApi} event * @return {boolean} * @private */ isKimaiSource(event) { if (event === null) { return false; } if (event.source === null) { return false; } return (event.source.id.indexOf('kimai-') === 0); } /** * @param {string} viewName * @returns {string} */ toExternalViewName(viewName) { switch(viewName) { case 'timeGridDay': return 'day'; case 'timeGridWeek': return 'week'; case 'dayGridMonth': default: return 'month'; } } /** * @param {string} viewName * @returns {string} */ toInternalViewName(viewName) { switch(viewName) { case 'day': case 'agendaDay': case 'timeGridDay': return 'timeGridDay'; case 'week': case 'agendaWeek': case 'timeGridWeek': return 'timeGridWeek'; case 'month': case 'agendaMonth': case 'dayGridMonth': default: return 'dayGridMonth'; } } /** * @param {string} name * @return {boolean} * @private */ hasPermission(name) { return this.options['permissions'][name]; } /** * @return {Calendar} */ getCalendar() { return this.calendar; } render() { this.calendar.render(); } reloadEvents() { this.calendar.getEventSources().forEach(source => source.refetch()); } /** * Only used on manipulated timesheets! * * @param {object} apiItem * @return {{activity, color: *, start, description, project, end, id, title: *, textColor: *, customer, tags: ([number,number,[],string,string]|*)}} * @private */ convertSourceForCalendar(apiItem) { const defaultColor = this.kimai.getConfiguration().get('defaultColor'); let color = apiItem.activity.color; if (color === null || color === defaultColor) { color = apiItem.project.color; if (color === null || color === defaultColor) { color = apiItem.project.customer.color; } } if (color === null) { color = defaultColor; } /** @type {KimaiDateUtils} DATES */ const DATES = this.kimai.getPlugin('date'); let title = this.options['patterns']['title']; title = title.replace('{project}', apiItem.project.name); title = title.replace('{customer}', apiItem.project.customer.name); title = title.replace('{description}', apiItem.description ?? ''); title = title.replace('{activity}', apiItem.activity.name ?? ''); if (apiItem.end === null) { // duration = 0 and end = null => is a running entry title = title.replace('{duration}', ''); } else { title = title.replace('{duration}', DATES.formatDuration(apiItem.duration)); } if (title === '' || title === null) { title = apiItem.activity.name; } return { id: apiItem.id, timesheet: apiItem.id, title: title, description: apiItem.description, exported: apiItem.exported, start: apiItem.begin, end: apiItem.end, activity: apiItem.activity.name, project: apiItem.project.name, customer: apiItem.project.customer.name, tags: apiItem.tags, color: color, textColor: KimaiColor.calculateContrastColor(color), }; } /** * @param {EventApi} event * @return {string} * @private */ renderEventPopoverContent(event) { const eventObj = event.extendedProps; /** @type {KimaiEscape} escaper */ const escaper = this.kimai.getPlugin('escape'); let tags = ''; if (eventObj.tags !== null && eventObj.tags.length > 0) { for (let tag of eventObj.tags) { tags += '' + escaper.escapeForHtml(tag) + ''; } } return `
` + (eventObj.description !== null || eventObj.tags.length > 0 ? '
' : '') + (eventObj.description ? '
' + escaper.escapeForHtml(eventObj.description) + '
' : '') + tags + `
`; } /** * @param {HTMLElement} element * @private */ hidePopover(element) { let popover = Popover.getInstance(element); if (popover !== null) { popover.hide(); } } /** * @param {EventDropArg} eventArg * @private */ changeHandler(eventArg) { /** @type {EventApi} event */ const event = eventArg.event; if (event.extendedProps.exported && !this.hasPermission('edit_exported')) { eventArg.revert(); return; } /** @type {KimaiAPI} API */ const API = this.kimai.getPlugin('api'); /** @type {KimaiDateUtils} DATE */ const DATES = this.kimai.getPlugin('date'); let payload = {'begin': DATES.formatForAPI(event.start)}; if (event.end !== null && event.end !== undefined) { payload.end = DATES.formatForAPI(event.end); } else { payload.end = null; } document.dispatchEvent(new CustomEvent('kimai.reloadContent')); const updateUrl = this.options.url.update(event.id); API.patch(updateUrl, JSON.stringify(payload), () => { document.dispatchEvent(new CustomEvent('kimai.reloadedContent')); }, (error) => { eventArg.revert(); document.dispatchEvent(new CustomEvent('kimai.reloadedContent')); API.handleError('action.update.error', error); }); } /** * @param {string} view * @param {EventApi[]} events * @private */ _renderDayAndWeekSum(view, events) { if (view === 'dayGridMonth') { // currently we do not display totals in month view return; } /** @type {KimaiDateUtils} DATES */ const DATES = this.kimai.getPlugin('date'); const durations = {}; if (view === 'timeGridWeek') { // make sure we have an entry for every day of the week, even days without timesheets document.querySelectorAll(`th.fc-col-header-cell[data-date]`).forEach(cell => { durations[cell.dataset.date] = 0; }); } events.forEach(item => { const start = DateTime.fromJSDate(item.start).toUTC(); const dateStr = start.toFormat('yyyy-MM-dd'); if (!durations[dateStr]) { durations[dateStr] = 0; } // absences or public holidays are all day if (item.end !== null) { const end = DateTime.fromJSDate(item.end).toUTC(); const duration = end.diff(start, 'hours').as('seconds'); durations[dateStr] += duration; } }); const dailyTotals = document.querySelectorAll('.fc-dailytotal'); dailyTotals.forEach(element => element.remove()); for (const dateValue in durations) { const durationValue = durations[dateValue]; if (view === 'timeGridWeek') { // this is the week view const headerCells = document.querySelectorAll(`th.fc-col-header-cell[data-date="${dateValue}"]`); headerCells.forEach(cell => { const newElement = document.createElement('div'); newElement.classList.add('fc-dailytotal'); newElement.textContent = DATES.formatSeconds(durationValue); cell.appendChild(newElement); }); } } // this is the day view if (view === 'timeGridDay') { const dayEl = document.querySelector('th.fc-day'); const dayDate = dayEl.dataset.date; const dayTotal = document.querySelectorAll('.fc-dailytotal'); dayTotal.forEach(element => element.remove()); const newElement = document.createElement('div'); newElement.classList.add('fc-dailytotal'); newElement.textContent = DATES.formatSeconds(durations[dayDate]); dayEl.appendChild(newElement); } } }