0
0
mirror of https://github.com/kevinpapst/kimai2.git synced 2024-12-22 12:18:29 +00:00
kevinpapst_kimai2/assets/js/widgets/KimaiCalendar.js
Kevin Papst 17a815e5a9
updated frontend builds (#5210)
* do not rely on node_modules path
* bump eslint to v9, run eslint via npm task, remove from build task
* loosen dependencies and update all packages
* rebuild assets with latest frontend packages
* bump webpack encore and dependencies
* bump to latest stable yarn
* explicitly mention dependencies
2024-12-06 14:31:04 +01:00

691 lines
28 KiB
JavaScript

/*
* 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";
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');
/** @type {KimaiAlert} ALERT */
const ALERT = this.kimai.getPlugin('alert');
let initialView = 'dayGridMonth';
switch (options['initialView']) {
case 'month':
initialView = 'dayGridMonth';
break;
case 'agendaWeek':
case 'week':
initialView = 'timeGridWeek';
break;
case 'agendaDay':
case 'day':
initialView = 'timeGridDay';
break;
}
// 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: initialView,
// 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'),
// auto calculation seems to do the better job, therefor deactivated
//slotLabelInterval: this.options['slotDuration'],
// 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 ]
// when we support holidays and other full day events
// allDaySlot: false,
// dropAccept
dayMaxEventRows: true,
eventMaxStack: this.options['dayLimit'],
dayMaxEvents: this.options['dayLimit'],
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.element);
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); });
}
});
},
};
// ============= 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) => {
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);
ALERT.success('action.update.success');
}
);
} else {
API.post(
apiUrl,
JSON.stringify(data),
(result) => {
const newItem = this.convertSourceForCalendar(result);
this.getCalendar().addEvent(newItem, true);
ALERT.success('action.update.success');
}
);
}
},
}};
}
// ============= 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} 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 += '<span class="badge bg-green">' + escaper.escapeForHtml(tag) + '</span>';
}
}
return `
<div class="calendar-entry">
<ul>
<li>` + this.options['translations']['customer'] + `: ` + escaper.escapeForHtml(eventObj.customer) + `</li>
<li>` + this.options['translations']['project'] + `: ` + escaper.escapeForHtml(eventObj.project) + `</li>
<li>` + this.options['translations']['activity'] + `: ` + escaper.escapeForHtml(eventObj.activity) + `</li>
</ul>` +
(eventObj.description !== null || eventObj.tags.length > 0 ? '<hr>' : '') +
(eventObj.description ? '<div>' + escaper.escapeForHtml(eventObj.description) + '</div>' : '') + tags + `
</div>`;
}
/**
* @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 {KimaiAlert} ALERT */
const ALERT = this.kimai.getPlugin('alert');
/** @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;
}
const updateUrl = this.options.url.update(event.id);
API.patch(updateUrl, JSON.stringify(payload), () => {
ALERT.success('action.update.success');
}, (error) => {
eventArg.revert();
API.handleError('action.update.error', error);
});
}
}