Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)
This commit is contained in:
618
assets/js/widgets/KimaiCalendar.js
Normal file
618
assets/js/widgets/KimaiCalendar.js
Normal file
@@ -0,0 +1,618 @@
|
||||
/*
|
||||
* 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']['next'],
|
||||
next: this.options['icons']['previous'],
|
||||
prevYear: this.options['icons']['nextYear'],
|
||||
nextYear: this.options['icons']['previousYear'],
|
||||
};
|
||||
|
||||
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;
|
||||
let popover = Popover.getInstance(element);
|
||||
|
||||
if (popover === null) {
|
||||
// https://getbootstrap.com/docs/5.0/components/popovers/#options
|
||||
popover = new Popover(element, {
|
||||
title: DATES.getFormattedDate(event.start) + ' | ' + DATES.formatTime(event.start) + ' - ' + (event.end ? DATES.formatTime(event.end) : ''),
|
||||
placement: 'top',
|
||||
html: true,
|
||||
content: this.renderEventPopoverContent(event),
|
||||
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);
|
||||
MODAL.openUrlInModal(this.options.url.edit(event.id));
|
||||
},
|
||||
}};
|
||||
|
||||
// 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) => {
|
||||
let url = source.url.replace('{from}', DATES.formatForAPI(fetchInfo.start));
|
||||
url = url.replace('{to}', DATES.formatForAPI(fetchInfo.end));
|
||||
|
||||
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 === '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());
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
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 (title === '' || title === null) {
|
||||
title = apiItem.activity.name;
|
||||
}
|
||||
|
||||
return {
|
||||
id: apiItem.id,
|
||||
timesheet: apiItem.id,
|
||||
title: title,
|
||||
description: apiItem.description,
|
||||
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');
|
||||
|
||||
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 ? '<p>' + eventObj.description + '</p>' : '') +
|
||||
(eventObj.tags !== null && eventObj.tags.length > 0 ? '<span class="badge bg-green">' + eventObj.tags.join('</span> <span class="badge bg-green">') + '</span>' : '') + `
|
||||
</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;
|
||||
/** @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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
38
assets/js/widgets/KimaiColor.js
Normal file
38
assets/js/widgets/KimaiColor.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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] KimaiColor: handle colors
|
||||
*/
|
||||
|
||||
export default class KimaiColor {
|
||||
|
||||
/**
|
||||
* @param {string} hexcolor
|
||||
* @return {string}
|
||||
*/
|
||||
static calculateContrastColor(hexcolor)
|
||||
{
|
||||
if (hexcolor.slice(0, 1) === '#') {
|
||||
hexcolor = hexcolor.slice(1);
|
||||
}
|
||||
|
||||
if (hexcolor.length === 3) {
|
||||
hexcolor = hexcolor.split('').map(function (hex) { return hex + hex; }).join('');
|
||||
}
|
||||
|
||||
const r = parseInt(hexcolor.substring(0,2),16);
|
||||
const g = parseInt(hexcolor.substring(2,4),16);
|
||||
const b = parseInt(hexcolor.substring(4,6),16);
|
||||
|
||||
// https://gomakethings.com/dynamically-changing-the-text-color-based-on-background-color-contrast-with-vanilla-js/
|
||||
const yiq = ((r * 299) + (g * 587) + (b * 114)) / 1000;
|
||||
|
||||
return (yiq >= 128) ? '#000000' : '#ffffff';
|
||||
}
|
||||
|
||||
}
|
||||
139
assets/js/widgets/KimaiContextMenu.js
Normal file
139
assets/js/widgets/KimaiContextMenu.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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] KimaiContextMenu: help to create, position and display context menus
|
||||
*/
|
||||
|
||||
export default class KimaiContextMenu {
|
||||
|
||||
/**
|
||||
* @param {string} id
|
||||
*/
|
||||
constructor(id)
|
||||
{
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
getContextMenuElement()
|
||||
{
|
||||
if (document.getElementById(this.id) === null) {
|
||||
const temp = document.createElement('div');
|
||||
temp.id = this.id;
|
||||
temp.classList.add('dropdown-menu', 'd-none');
|
||||
document.body.appendChild(temp);
|
||||
}
|
||||
|
||||
return document.getElementById(this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
* @param {object} json
|
||||
*/
|
||||
createFromApi(event, json)
|
||||
{
|
||||
let html = '';
|
||||
|
||||
for (const options of json) {
|
||||
if (options['divider'] === true) {
|
||||
html += '<div class="dropdown-divider"></div>';
|
||||
}
|
||||
|
||||
if (options['url'] !== null) {
|
||||
html += '<a class="dropdown-item ' + (options['class'] !== null ? options['class'] : '') + '" href="' + options['url'] + '"';
|
||||
|
||||
if (options['attr'] !== undefined) {
|
||||
for (const attrName in options['attr']) {
|
||||
html += ' ' + attrName + '="' + options['attr'][attrName].replaceAll('"', '"') + '"';
|
||||
}
|
||||
}
|
||||
html += '>' + options['title'] + '</a>';
|
||||
}
|
||||
}
|
||||
|
||||
this.createFromClickEvent(event, html);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {MouseEvent} event
|
||||
* @param {string} html
|
||||
*/
|
||||
createFromClickEvent(event, html)
|
||||
{
|
||||
const dropdownElement = this.getContextMenuElement();
|
||||
|
||||
dropdownElement.style.zIndex = '1021'; // stay on top of sticky elements (like table header)
|
||||
dropdownElement.innerHTML = html;
|
||||
dropdownElement.style.position = 'fixed';
|
||||
dropdownElement.style.top = (event.clientY) + 'px';
|
||||
dropdownElement.style.left = (event.clientX) + 'px';
|
||||
|
||||
const dropdownListener = () => {
|
||||
dropdownElement.classList.remove('d-block');
|
||||
if (!dropdownElement.classList.contains('d-none')) {
|
||||
dropdownElement.classList.add('d-none');
|
||||
}
|
||||
dropdownElement.removeEventListener('click', dropdownListener);
|
||||
document.removeEventListener('click', dropdownListener);
|
||||
}
|
||||
|
||||
dropdownElement.addEventListener('click', dropdownListener);
|
||||
document.addEventListener('click', dropdownListener);
|
||||
|
||||
dropdownElement.classList.remove('d-none');
|
||||
if (!dropdownElement.classList.contains('d-block')) {
|
||||
dropdownElement.classList.add('d-block');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} selector
|
||||
*/
|
||||
static createForDataTable(selector)
|
||||
{
|
||||
[].slice.call(document.querySelectorAll(selector)).map((dataTable) => {
|
||||
const actions = dataTable.querySelector('td.actions div.dropdown-menu');
|
||||
if (actions === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
dataTable.addEventListener('contextmenu', (jsEvent) => {
|
||||
let target = jsEvent.target;
|
||||
while (target !== null) {
|
||||
const tagName = target.tagName.toUpperCase();
|
||||
if (tagName === 'TH' || tagName === 'TABLE' || tagName === 'BODY') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tagName === 'TR') {
|
||||
break;
|
||||
}
|
||||
|
||||
target = target.parentNode;
|
||||
}
|
||||
|
||||
if (target === null || !target.matches('table.dataTable tbody tr')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actions = target.querySelector('td.actions div.dropdown-menu');
|
||||
if (actions === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
jsEvent.preventDefault();
|
||||
|
||||
const contextMenu = new KimaiContextMenu(dataTable.dataset['contextMenu']);
|
||||
contextMenu.createFromClickEvent(jsEvent, actions.innerHTML);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/*
|
||||
* 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] KimaiCookies: simple wrapper to handle cookies
|
||||
*/
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
export default class KimaiCookies {
|
||||
|
||||
static set(name, values, options) {
|
||||
Cookies.set(name, values, options);
|
||||
}
|
||||
|
||||
static get(name) {
|
||||
return Cookies.getJSON(name);
|
||||
}
|
||||
|
||||
static remove(name) {
|
||||
Cookies.remove(name);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -9,32 +9,42 @@
|
||||
* [KIMAI] KimaiPaginatedBoxWidget: handles box widgets that have a pagination
|
||||
*/
|
||||
|
||||
import jQuery from "jquery";
|
||||
import KimaiContextMenu from "./KimaiContextMenu";
|
||||
|
||||
export default class KimaiPaginatedBoxWidget {
|
||||
|
||||
constructor(boxId) {
|
||||
this.selector = boxId;
|
||||
this.overlay = jQuery('<div class="overlay"><div class="fas fa-sync fa-spin"></div></div>');
|
||||
this.widget = jQuery(this.selector);
|
||||
this.href = this.widget.data('href');
|
||||
this.events = this.widget.data('reload').split(' ');
|
||||
const widget = document.querySelector(this.selector);
|
||||
this.href = widget.dataset['href'];
|
||||
|
||||
const self = this;
|
||||
if (widget.dataset['reload'] !== undefined) {
|
||||
this.events = widget.dataset['reload'].split(' ');
|
||||
const reloadPage = () => {
|
||||
let url = null;
|
||||
if (document.querySelector(this.selector).dataset['reloadHref'] !== undefined) {
|
||||
url = document.querySelector(this.selector).dataset['reloadHref'];
|
||||
} else {
|
||||
url = document.querySelector(this.selector + ' ul.pagination li.active a').href;
|
||||
}
|
||||
this.loadPage(url);
|
||||
};
|
||||
|
||||
const reloadPage = function (event) {
|
||||
const page = jQuery(self.selector + ' .box-tools').data('page');
|
||||
const url = self._buildUrl(page);
|
||||
self.loadPage(url);
|
||||
};
|
||||
|
||||
for (const eventName of this.events) {
|
||||
document.addEventListener(eventName, reloadPage);
|
||||
for (const eventName of this.events) {
|
||||
document.addEventListener(eventName, reloadPage);
|
||||
}
|
||||
}
|
||||
|
||||
this.widget.on('click', '.box-tools ul.pagination a', function (event) {
|
||||
event.preventDefault();
|
||||
self.loadPage(jQuery(event.currentTarget).attr('href'));
|
||||
document.body.addEventListener('click', (event) => {
|
||||
let link = event.target;
|
||||
// could be an icon
|
||||
if (!link.matches(this.selector + ' a.pagination-link')) {
|
||||
link = link.parentNode;
|
||||
}
|
||||
if (link.matches(this.selector + ' a.pagination-link')) {
|
||||
event.preventDefault();
|
||||
this.loadPage(link.href);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,56 +52,54 @@ export default class KimaiPaginatedBoxWidget {
|
||||
return new KimaiPaginatedBoxWidget(elementId);
|
||||
}
|
||||
|
||||
_showOverlay() {
|
||||
this.widget.append(this.overlay);
|
||||
}
|
||||
|
||||
_hideOverlay() {
|
||||
jQuery(this.overlay).remove();
|
||||
}
|
||||
|
||||
loadPage(url) {
|
||||
const self = this;
|
||||
const selector = this.selector;
|
||||
|
||||
self._showOverlay();
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
data: {},
|
||||
success: function (response) {
|
||||
const html = jQuery(response);
|
||||
jQuery(selector + ' .box-tools').replaceWith(html.find('.box-tools'));
|
||||
jQuery(selector + ' .box-body').replaceWith(html.find('.box-body'));
|
||||
jQuery(selector + ' .box-title').replaceWith(html.find('.box-title'));
|
||||
if (jQuery(selector + ' .box-footer').length > 0) {
|
||||
jQuery(selector + ' .box-footer').replaceWith(html.find('.box-footer'));
|
||||
}
|
||||
jQuery(selector + ' .box-body [data-toggle="tooltip"]').tooltip();
|
||||
self.widget.removeData('error-retry');
|
||||
self._hideOverlay();
|
||||
},
|
||||
dataType: 'html',
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
if (jqXHR.status !== undefined && jqXHR.status === 500) {
|
||||
if (self.widget.data('error-retry') !== undefined) {
|
||||
// TODO show error message ?
|
||||
return;
|
||||
}
|
||||
const page = jQuery(selector + ' .box-tools').data('page');
|
||||
if (page > 1) {
|
||||
self.widget.data('error-retry', 1);
|
||||
var url = self._buildUrl(page - 1);
|
||||
self.loadPage(url);
|
||||
}
|
||||
}
|
||||
self._hideOverlay();
|
||||
// this event will render a spinning loader
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent', {detail: this.selector}));
|
||||
|
||||
// and this event will hide it afterwards
|
||||
const hideOverlay = () => {
|
||||
document.dispatchEvent(new Event('kimai.reloadedContent'));
|
||||
}
|
||||
|
||||
window.kimai.getPlugin('fetch').fetch(url)
|
||||
.then(response => {
|
||||
response.text().then((text) => {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = text;
|
||||
// previously the parts .card-header .card-body .card-title .card-footer were replaced
|
||||
// but the layout allows eg. ".list-group .list-group-flush" instead of .card-body
|
||||
// so we directly replace the entire HTML
|
||||
// the HTML needs to be parsed for script tags, which can be included (e.g. paginated chart widget)
|
||||
document.querySelector(selector).replaceWith(this._makeScriptExecutable(temp.firstElementChild));
|
||||
KimaiContextMenu.createForDataTable(selector + ' table.dataTable');
|
||||
hideOverlay();
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// this is not yet a plugin, so the alert is not available here
|
||||
window.kimai.getPlugin('alert').error('Failed loading selected page');
|
||||
hideOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Element|ChildNode} node
|
||||
* @returns {Element}
|
||||
* @private
|
||||
*/
|
||||
_makeScriptExecutable(node) {
|
||||
if (node.tagName !== undefined && node.tagName === 'SCRIPT') {
|
||||
const script = document.createElement('script');
|
||||
script.text = node.innerHTML;
|
||||
node.parentNode.replaceChild(script, node );
|
||||
} else {
|
||||
for (const child of node.childNodes) {
|
||||
this._makeScriptExecutable(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
_buildUrl(page) {
|
||||
return this.href.replace('1', page);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -9,21 +9,14 @@
|
||||
* [KIMAI] KimaiReloadPageWidget: a simple helper to reload the page on events
|
||||
*/
|
||||
|
||||
import jQuery from "jquery";
|
||||
|
||||
export default class KimaiReloadPageWidget {
|
||||
|
||||
constructor(events, fullReload) {
|
||||
this.overlay = jQuery('<div class="overlay-wrapper"><div class="overlay"><div class="fas fa-sync fa-spin"></div></div></div>');
|
||||
this.widget = jQuery('div.content-wrapper');
|
||||
|
||||
const self = this;
|
||||
|
||||
const reloadPage = function (event) {
|
||||
const reloadPage = () => {
|
||||
if (fullReload) {
|
||||
document.location.reload(true);
|
||||
document.location.reload();
|
||||
} else {
|
||||
self.loadPage(document.location);
|
||||
this._loadPage(document.location);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -40,34 +33,31 @@ export default class KimaiReloadPageWidget {
|
||||
}
|
||||
|
||||
_showOverlay() {
|
||||
this.widget.append(this.overlay);
|
||||
document.dispatchEvent(new CustomEvent('kimai.reloadContent', {detail: 'div.page-wrapper'}));
|
||||
}
|
||||
|
||||
_hideOverlay() {
|
||||
jQuery(this.overlay).remove();
|
||||
}
|
||||
|
||||
loadPage(url) {
|
||||
const self = this;
|
||||
|
||||
self._showOverlay();
|
||||
|
||||
jQuery.ajax({
|
||||
url: url,
|
||||
data: {},
|
||||
success: function (response) {
|
||||
jQuery('section.content').replaceWith(
|
||||
jQuery(response).find('section.content')
|
||||
);
|
||||
document.dispatchEvent(new Event('kimai.reloadPage'));
|
||||
self._hideOverlay();
|
||||
},
|
||||
dataType: 'html',
|
||||
error: function(jqXHR, textStatus, errorThrown) {
|
||||
self._hideOverlay();
|
||||
_hideOverlay() {
|
||||
document.dispatchEvent(new Event('kimai.reloadedContent'));
|
||||
}
|
||||
|
||||
_loadPage(url) {
|
||||
this._showOverlay();
|
||||
|
||||
window.kimai.getPlugin('fetch').fetch(url)
|
||||
.then(response => {
|
||||
response.text().then((text) => {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = text;
|
||||
const newContent = temp.querySelector('section.content');
|
||||
document.querySelector('section.content').replaceWith(newContent);
|
||||
document.dispatchEvent(new Event('kimai.reloadPage'));
|
||||
this._hideOverlay();
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
this._hideOverlay();
|
||||
document.location = url;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user