Next major version 2 with PHP 8.1, Symfony 6, Tabler UI, 2FA ... (#2902)

This commit is contained in:
Kevin Papst
2022-12-31 21:19:55 +01:00
committed by GitHub
parent 95e06746bd
commit 90a0fd8a22
2164 changed files with 83700 additions and 86426 deletions

View File

@@ -1,96 +1,3 @@
// ------------------- INLINED ADMIN-LTE DEFINITIONS -------------------
// require('../vendor/kevinpapst/adminlte-bundle/Resources/assets/admin-lte');
// this was replaced to save around 300kb by:
// - removing moment locales which are not used
// - removing fullcalendar locales which are not used
// - removing icheck which is not used
// - removing jquery-ui which is not used
const $ = require('jquery');
global.$ = global.jQuery = $;
require('bootstrap-sass');
require('jquery-slimscroll');
require('select2');
require('select2/dist/js/i18n/ar');
require('select2/dist/js/i18n/cs');
require('select2/dist/js/i18n/da');
require('select2/dist/js/i18n/de');
require('select2/dist/js/i18n/el');
require('select2/dist/js/i18n/es');
require('select2/dist/js/i18n/eu');
require('select2/dist/js/i18n/fa');
require('select2/dist/js/i18n/fi');
require('select2/dist/js/i18n/fr');
require('select2/dist/js/i18n/he');
require('select2/dist/js/i18n/hr');
require('select2/dist/js/i18n/hu');
require('select2/dist/js/i18n/it');
require('select2/dist/js/i18n/ja');
require('select2/dist/js/i18n/ko');
require('select2/dist/js/i18n/nb');
require('select2/dist/js/i18n/nl');
require('select2/dist/js/i18n/pl');
require('select2/dist/js/i18n/pt');
require('select2/dist/js/i18n/pt-BR');
require('select2/dist/js/i18n/ro');
require('select2/dist/js/i18n/ru');
require('select2/dist/js/i18n/sk');
require('select2/dist/js/i18n/sv');
require('select2/dist/js/i18n/tr');
require('select2/dist/js/i18n/vi');
require('select2/dist/js/i18n/zh-CN');
const Moment = require('moment');
global.moment = Moment;
require('moment/locale/ar');
require('moment/locale/cs');
require('moment/locale/da');
require('moment/locale/de');
require('moment/locale/de-at');
require('moment/locale/de-ch');
require('moment/locale/el');
require('moment/locale/eo');
require('moment/locale/es');
require('moment/locale/eu');
require('moment/locale/fa');
require('moment/locale/fi');
require('moment/locale/fo');
require('moment/locale/fr');
require('moment/locale/he');
require('moment/locale/hr');
require('moment/locale/hu');
require('moment/locale/it');
require('moment/locale/ja');
require('moment/locale/ko');
require('moment/locale/nb');
require('moment/locale/nl');
require('moment/locale/pl');
require('moment/locale/pt');
require('moment/locale/pt-br');
require('moment/locale/ro');
require('moment/locale/ru');
require('moment/locale/sk');
require('moment/locale/sv');
require('moment/locale/tr');
require('moment/locale/vi');
require('moment/locale/zh-cn');
require('daterangepicker');
// ------ AdminLTE framework ------
require('./sass/bootstrap.scss');
require('./sass/fontawesome.scss');
require('admin-lte/dist/css/AdminLTE.min.css');
require('admin-lte/dist/css/skins/_all-skins.css');
require('../vendor/kevinpapst/adminlte-bundle/Resources/assets/admin-lte-extensions.scss');
global.$.AdminLTE = {};
global.$.AdminLTE.options = {};
require('admin-lte/dist/js/adminlte.min');
// ------------------- INLINED ADMIN-LTE DEFINITIONS -------------------
// ---------------------------------------------------------------------
require('./sass/app.scss');
@@ -98,8 +5,5 @@ require('./sass/app.scss');
require('./js/KimaiWebLoader.js');
global.KimaiPaginatedBoxWidget = require('./js/widgets/KimaiPaginatedBoxWidget').default;
global.KimaiReloadPageWidget = require('./js/widgets/KimaiReloadPageWidget').default;
global.KimaiCookies = require('./js/widgets/KimaiCookies').default;
global.KimaiColor = require('./js/widgets/KimaiColor').default;
global.KimaiStorage = require('./js/widgets/KimaiStorage').default;
// ------ Autocomplete for tags only ------
require('jquery-ui/ui/widgets/autocomplete');

View File

@@ -1,40 +1,2 @@
// can be removed, once fullcalendar was updated and works without draggable jquery ui objects
require('jquery-ui/ui/widgets/draggable');
require('fullcalendar');
require('fullcalendar/dist/gcal.min');
require('fullcalendar/dist/locale/ar');
require('fullcalendar/dist/locale/cs');
require('fullcalendar/dist/locale/da');
require('fullcalendar/dist/locale/de');
require('fullcalendar/dist/locale/de-at');
require('fullcalendar/dist/locale/de-ch');
require('fullcalendar/dist/locale/el');
require('fullcalendar/dist/locale/es');
require('fullcalendar/dist/locale/eu');
require('fullcalendar/dist/locale/fa');
require('fullcalendar/dist/locale/fi');
require('fullcalendar/dist/locale/fr');
require('fullcalendar/dist/locale/he');
require('fullcalendar/dist/locale/hr');
require('fullcalendar/dist/locale/hu');
require('fullcalendar/dist/locale/it');
require('fullcalendar/dist/locale/ja');
require('fullcalendar/dist/locale/ko');
require('fullcalendar/dist/locale/nb');
require('fullcalendar/dist/locale/nl');
require('fullcalendar/dist/locale/pl');
require('fullcalendar/dist/locale/pt');
require('fullcalendar/dist/locale/pt-br');
require('fullcalendar/dist/locale/ro');
require('fullcalendar/dist/locale/ru');
require('fullcalendar/dist/locale/sk');
require('fullcalendar/dist/locale/sv');
require('fullcalendar/dist/locale/tr');
require('fullcalendar/dist/locale/zh-cn');
require('fullcalendar/dist/locale/vi');
require('fullcalendar/dist/locale/en-gb'); // the last imported file is used as fallback for locales that do not exist (like EO)
require('fullcalendar/dist/fullcalendar.min.css');
global.KimaiCalendar = require('./js/widgets/KimaiCalendar').default;

View File

@@ -1,2 +1,56 @@
import {
Chart,
ArcElement,
LineElement,
BarElement,
PointElement,
BarController,
// BubbleController,
DoughnutController,
LineController,
PieController,
// PolarAreaController,
// RadarController,
// ScatterController,
CategoryScale,
LinearScale,
// LogarithmicScale,
// RadialLinearScale,
// TimeScale,
// TimeSeriesScale,
// Decimation,
// Filler,
Legend,
Title,
Tooltip,
// SubTitle
} from 'chart.js';
require('chart.js/dist/Chart.min');
Chart.register(
ArcElement,
LineElement,
BarElement,
PointElement,
BarController,
// BubbleController,
DoughnutController,
LineController,
PieController,
// PolarAreaController,
// RadarController,
// ScatterController,
CategoryScale,
LinearScale,
// LogarithmicScale,
// RadialLinearScale,
// TimeScale,
// TimeSeriesScale,
// Decimation,
// Filler,
Legend,
Title,
Tooltip,
// SubTitle
);
global.Chart = Chart;

8
assets/dashboard.js Normal file
View File

@@ -0,0 +1,8 @@
/**
* https://gridstackjs.com
* https://github.com/gridstack/gridstack.js/tree/master/doc
*/
require('gridstack/dist/gridstack.min.css');
require('gridstack/dist/gridstack-extra.min.css');
import { GridStack } from 'gridstack';
global.GridStack = GridStack;

2
assets/export-pdf.js Normal file
View File

@@ -0,0 +1,2 @@
require('./sass/export-pdf.scss');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -23,4 +23,31 @@ export default class KimaiConfiguration {
return name in this._configurations;
}
isRTL() {
return this.get('direction') === 'rtl';
}
getLanguage() {
return this.get('locale').replace('_', '-');
}
is24Hours() {
return !!this.get('twentyFourHours');
}
/**
* @param {boolean} iso
* @return {number}
*/
getFirstDayOfWeek(iso = true) {
if (iso === undefined) {
iso = true;
}
let config = this.get('first_dow_iso');
if (!iso) {
config = config % 7;
}
return config;
}
}

View File

@@ -20,8 +20,8 @@ export default class KimaiContainer {
/**
* Create a new Container with the given configurations and translations.
*
* @param {Object} configuration
* @param {Object} translation
* @param {KimaiConfiguration} configuration
* @param {KimaiTranslation} translation
*/
constructor(configuration, translation) {
if (!(configuration instanceof KimaiConfiguration)) {

View File

@@ -9,19 +9,16 @@
* [KIMAI] KimaiLoader: bootstrap the application and all plugins
*/
import moment from 'moment';
import { Settings } from 'luxon';
import KimaiTranslation from "./KimaiTranslation";
import KimaiConfiguration from "./KimaiConfiguration";
import KimaiContainer from "./KimaiContainer";
import KimaiActiveRecordsDuration from './plugins/KimaiActiveRecordsDuration.js';
import KimaiDatatableColumnView from './plugins/KimaiDatatableColumnView.js';
import KimaiThemeInitializer from "./plugins/KimaiThemeInitializer";
import KimaiDateRangePicker from "./plugins/KimaiDateRangePicker";
import KimaiDateRangePicker from "./forms/KimaiDateRangePicker";
import KimaiDatatable from "./plugins/KimaiDatatable";
import KimaiToolbar from "./plugins/KimaiToolbar";
import KimaiAPI from "./plugins/KimaiAPI";
import KimaiSelectDataAPI from "./plugins/KimaiSelectDataAPI";
import KimaiDateTimePicker from "./plugins/KimaiDateTimePicker";
import KimaiAlternativeLinks from "./plugins/KimaiAlternativeLinks";
import KimaiAjaxModalForm from "./plugins/KimaiAjaxModalForm";
import KimaiActiveRecords from "./plugins/KimaiActiveRecords";
@@ -29,60 +26,76 @@ import KimaiRecentActivities from "./plugins/KimaiRecentActivities";
import KimaiEvent from "./plugins/KimaiEvent";
import KimaiAPILink from "./plugins/KimaiAPILink";
import KimaiAlert from "./plugins/KimaiAlert";
import KimaiAutocomplete from "./plugins/KimaiAutocomplete";
import KimaiFormSelect from "./plugins/KimaiFormSelect";
import KimaiAutocomplete from "./forms/KimaiAutocomplete";
import KimaiFormSelect from "./forms/KimaiFormSelect";
import KimaiForm from "./plugins/KimaiForm";
import KimaiDatePicker from "./plugins/KimaiDatePicker";
import KimaiDatePicker from "./forms/KimaiDatePicker";
import KimaiConfirmationLink from "./plugins/KimaiConfirmationLink";
import KimaiMultiUpdateTable from "./plugins/KimaiMultiUpdateTable";
import KimaiDateUtils from "./plugins/KimaiDateUtils";
import KimaiEscape from "./plugins/KimaiEscape";
import KimaiFetch from "./plugins/KimaiFetch";
import KimaiTimesheetForm from "./forms/KimaiTimesheetForm";
import KimaiTeamForm from "./forms/KimaiTeamForm";
import KimaiCopyDataForm from "./forms/KimaiCopyDataForm";
import KimaiDateNowForm from "./forms/KimaiDateNowForm";
import KimaiNotification from "./plugins/KimaiNotification";
import KimaiHotkeys from "./plugins/KimaiHotkeys";
export default class KimaiLoader {
constructor(configurations, translations) {
// set the current locale for all javascript components
moment.locale(configurations['locale'].replace('_', '-').toLowerCase());
Settings.defaultLocale = configurations['locale'].replace('_', '-').toLowerCase();
Settings.defaultZone = configurations['timezone'];
const kimai = new KimaiContainer(
new KimaiConfiguration(configurations),
new KimaiTranslation(translations)
);
// GLOBAL HELPER PLUGINS
kimai.registerPlugin(new KimaiEscape());
kimai.registerPlugin(new KimaiEvent());
kimai.registerPlugin(new KimaiAPI());
kimai.registerPlugin(new KimaiAlert());
kimai.registerPlugin(new KimaiFetch());
kimai.registerPlugin(new KimaiDateUtils());
kimai.registerPlugin(new KimaiFormSelect('.selectpicker'));
kimai.registerPlugin(new KimaiNotification());
// FORM PLUGINS
kimai.registerPlugin(new KimaiFormSelect('.selectpicker', 'select[data-related-select]'));
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepicker="on"]'));
kimai.registerPlugin(new KimaiDatePicker('input[data-datepicker="on"]'));
kimai.registerPlugin(new KimaiAutocomplete());
kimai.registerPlugin(new KimaiTimesheetForm());
kimai.registerPlugin(new KimaiTeamForm());
kimai.registerPlugin(new KimaiCopyDataForm());
kimai.registerPlugin(new KimaiDateNowForm());
kimai.registerPlugin(new KimaiForm());
kimai.registerPlugin(new KimaiHotkeys());
// SPECIAL FEATURES
kimai.registerPlugin(new KimaiConfirmationLink('confirmation-link'));
kimai.registerPlugin(new KimaiActiveRecordsDuration());
kimai.registerPlugin(new KimaiDatatableColumnView('data-column-visibility'));
kimai.registerPlugin(new KimaiDateRangePicker('input[data-daterangepickerenable="on"]'));
kimai.registerPlugin(new KimaiDateTimePicker('input[data-datetimepicker="on"]'));
kimai.registerPlugin(new KimaiDatePicker('input[data-datepickerenable="on"]'));
kimai.registerPlugin(new KimaiDatatable('section.content', 'table.dataTable'));
kimai.registerPlugin(new KimaiToolbar('form.searchform', 'toolbar-action'));
kimai.registerPlugin(new KimaiSelectDataAPI('select[data-related-select]'));
kimai.registerPlugin(new KimaiAlternativeLinks('.alternative-link'));
kimai.registerPlugin(new KimaiAjaxModalForm('.modal-ajax-form'));
kimai.registerPlugin(new KimaiRecentActivities('li.notifications-menu'));
kimai.registerPlugin(new KimaiActiveRecords('li.messages-menu', 'li.messages-menu-empty'));
kimai.registerPlugin(new KimaiRecentActivities());
kimai.registerPlugin(new KimaiActiveRecords('header .messages-menu', 'header .messages-menu-empty'));
kimai.registerPlugin(new KimaiAPILink('api-link'));
kimai.registerPlugin(new KimaiAutocomplete('.js-autocomplete'));
kimai.registerPlugin(new KimaiForm());
kimai.registerPlugin(new KimaiThemeInitializer());
kimai.registerPlugin(new KimaiMultiUpdateTable());
//kimai.registerPlugin(new KimaiPauseRecord('li.messages-menu ul.menu li'));
kimai.registerPlugin(new KimaiThemeInitializer());
// notify all listeners that Kimai plugins can now be registered
kimai.getPlugin('event').trigger('kimai.pluginRegister', {'kimai': kimai});
document.dispatchEvent(new CustomEvent('kimai.pluginRegister', {detail: {'kimai': kimai}}));
// initialize all plugins
kimai.getPlugins().map(plugin => { plugin.init(); });
// notify all listeners that Kimai is now ready to be used
kimai.getPlugin('event').trigger('kimai.initialized', {'kimai': kimai});
document.dispatchEvent(new CustomEvent('kimai.initialized', {detail: {'kimai': kimai}}));
this.kimai = kimai;
}

View File

@@ -58,6 +58,20 @@ export default class KimaiPlugin {
return this.getContainer().getConfiguration().get(name);
}
/**
* @return {KimaiConfiguration}
*/
getConfigurations() {
return this.getContainer().getConfiguration();
}
/**
* @returns {KimaiDateUtils}
*/
getDateUtils() {
return this.getPlugin('date');
}
/**
* @param {string} name
* @returns {KimaiPlugin}
@@ -66,11 +80,82 @@ export default class KimaiPlugin {
return this.getContainer().getPlugin(name);
}
/**
* @returns {KimaiTranslation}
*/
getTranslation() {
return this.getContainer().getTranslation();
}
/**
* @param {string} name
* @returns {string}
*/
translate(name) {
return this.getTranslation().get(name);
}
/**
* @param {string} title
* @returns {string}
*/
escape(title) {
return this.getPlugin('escape').escapeForHtml(title);
};
}
/**
* @param {string} name
* @param {string|null} details
*/
trigger(name, details = null) {
this.getPlugin('event').trigger(name, details);
}
/**
* @param {string} url
* @param {object} options
* @returns {Promise<Response>}
*/
fetch(url, options = {}) {
return this.getPlugin('fetch').fetch(url, options);
}
/**
* @param {HTMLFormElement} form
* @param {object} options
* @param {string|null} url
* @returns {Promise<Response>}
*/
fetchForm(form, options = {}, url = null) {
url = url || form.getAttribute('action');
const method = form.getAttribute('method').toUpperCase();
if (method === 'GET') {
const data = this.getPlugin('form').convertFormDataToQueryString(form, {}, true);
// TODO const data = new URLSearchParams(new FormData(form)).toString();
url = url + (url.includes('?') ? '&' : '?') + data;
options = {...{method: 'GET'}, ...options};
} else if (method === 'POST') {
options = {...{
method: 'POST',
body: new FormData(form)
}, ...options};
}
return this.fetch(url, options);
}
/**
* Check if the current device is a mobile device (targeting the bootstrip xs breakpoint size).
*
* @returns {boolean}
*/
isMobile() {
const width = Math.max(
document.documentElement.clientWidth,
window.innerWidth || 0
)
return width < 576;
}
}

View File

@@ -0,0 +1,97 @@
/*
* 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.
*/
import TomSelect from 'tom-select';
import KimaiFormPlugin from "./KimaiFormPlugin";
/**
* Supporting auto-complete fields via API.
* Used for timesheet tagging in toolbar and edit dialogs.
*/
export default class KimaiAutocomplete extends KimaiFormPlugin {
init()
{
this.selector = '[data-form-widget="autocomplete"]';
}
/**
* @param {HTMLFormElement} form
* @return boolean
*/
supportsForm(form) // eslint-disable-line no-unused-vars
{
return true;
}
activateForm(form)
{
/** @type {KimaiAPI} API */
const API = this.getContainer().getPlugin('api');
[].slice.call(form.querySelectorAll(this.selector)).map((node) => {
const apiUrl = node.dataset['autocompleteUrl'];
let minChars = 3;
if (node.dataset['minimumCharacter'] !== undefined) {
minChars = parseInt(node.dataset['minimumCharacter']);
}
new TomSelect(node, {
// if there are more than 500, they need to be found by "typing"
maxOptions: 500,
// the autocomplete is ONLY used, when the user can create tags
create: node.dataset['create'] !== undefined,
onOptionAdd: (value) => {
node.dispatchEvent(new CustomEvent('create', {detail: {'value': value}}));
},
plugins: ['remove_button'],
shouldLoad: function(query) {
return query.length >= minChars;
},
load: (query, callback) => {
API.get(apiUrl, {'name': query}, (data) => {
const results = [].slice.call(data).map((result) => {
return {text: result, value: result};
});
callback(results);
}, () => {
callback();
});
},
render: {
// eslint-disable-next-line
not_loading: (data, escape) => {
// no default content
},
option_create: (data, escape) => {
const name = escape(data.input);
if (name.length < 3) {
return null;
}
const tpl = this.translate('select.search.create');
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>')
return '<div class="create">' + tplReplaced + '</div>';
},
no_results: (data, escape) => {
const tpl = this.translate('select.search.notfound');
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>')
return '<div class="no-results">' + tplReplaced + '</div>';
},
},
});
});
}
destroyForm(form) {
[].slice.call(form.querySelectorAll(this.selector)).map((node) => {
if (node.tomselect) {
node.tomselect.destroy();
}
});
}
}

View File

@@ -0,0 +1,71 @@
/*
* 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] KimaiEditTimesheetForm: responsible for the most important form in the application
*/
import KimaiFormPlugin from "./KimaiFormPlugin";
/**
* Used for simple copy from link to input action, e.g. the time and duration dropdowns
* copy the selected values into their corresponding input.
*/
export default class KimaiCopyDataForm extends KimaiFormPlugin {
/**
* @param {HTMLFormElement} form
* @return boolean
*/
supportsForm(form) // eslint-disable-line no-unused-vars
{
return true;
}
/**
* @param {HTMLFormElement} form
*/
activateForm(form) // eslint-disable-line no-unused-vars
{
if (this._eventHandler === undefined) {
this._eventHandler = (event) => {
let element = event.target;
if (!element.matches('a[data-form-widget="copy-data"]')) {
element = element.parentNode; // mostly for icons
}
if (!element.matches('a[data-form-widget="copy-data"]') || element.dataset.target === undefined) {
return;
}
const target = document.querySelector(element.dataset.target);
if (target === null) {
return;
}
target.value = element.dataset.value;
if (element.dataset.event !== undefined) {
for (const event of element.dataset.event.split(' ')) {
target.dispatchEvent(new Event(event));
}
} else if (element.dataset.eventBubbles !== undefined) {
for (const event of element.dataset.eventBubbles.split(' ')) {
target.dispatchEvent(new Event(event, {bubbles: true}));
}
}
event.preventDefault();
};
}
form.addEventListener('click', this._eventHandler);
}
/**
* @param {HTMLFormElement} form
*/
destroyForm(form) // eslint-disable-line no-unused-vars
{
form.removeEventListener('click', this._eventHandler);
}
}

View File

@@ -0,0 +1,70 @@
/*
* 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] KimaiEditTimesheetForm: responsible for the most important form in the application
*/
import KimaiFormPlugin from "./KimaiFormPlugin";
/**
*/
export default class KimaiDateNowForm extends KimaiFormPlugin {
init()
{
this.selector = 'a[data-form-widget="date-now"]';
}
/**
* @param {HTMLFormElement} form
* @return boolean
*/
supportsForm(form) // eslint-disable-line no-unused-vars
{
return true;
}
/**
* @param {HTMLFormElement} form
*/
activateForm(form)
{
[].slice.call(form.querySelectorAll(this.selector)).map((element) => {
if (element.dataset.format !== undefined && element.dataset.target !== undefined) {
if (this._eventHandler === undefined) {
this._eventHandler = (event) => {
const linkTarget = event.currentTarget;
const formElement = document.getElementById(linkTarget.dataset.target);
if (!formElement.disabled) {
formElement.value = this.getDateUtils().format(linkTarget.dataset.format, null);
formElement.dispatchEvent(new Event('change', {bubbles: true}));
}
event.preventDefault();
};
}
element.addEventListener('click', this._eventHandler);
}
});
}
/**
* @param {HTMLFormElement} form
*/
destroyForm(form)
{
[].slice.call(form.querySelectorAll(this.selector)).map((element) => {
if (element.dataset.format !== undefined && element.dataset.target !== undefined) {
element.removeEventListener('click', this._eventHandler);
}
});
}
}

View File

@@ -0,0 +1,113 @@
/*
* 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] KimaiDatePicker: single date selects (currently unused)
*/
import { Litepicker } from 'litepicker';
import 'litepicker/dist/plugins/mobilefriendly';
import KimaiFormPlugin from "./KimaiFormPlugin";
export default class KimaiDatePicker extends KimaiFormPlugin {
constructor(selector)
{
super();
this._selector = selector;
}
init()
{
window.disableLitepickerStyles = true;
this._pickers = [];
}
/**
* @param {HTMLFormElement} form
* @return boolean
*/
supportsForm(form) // eslint-disable-line no-unused-vars
{
return true;
}
/**
* @param {HTMLFormElement} form
*/
activateForm(form)
{
const FIRST_DOW = this.getConfigurations().getFirstDayOfWeek(false);
const LANGUAGE = this.getConfigurations().getLanguage();
let options = {
buttonText: {
previousMonth: `<i class="fas fa-chevron-left"></i>`,
nextMonth: `<i class="fas fa-chevron-right"></i>`,
apply: this.translate('confirm'),
cancel: this.translate('cancel'),
},
};
const newPickers = [].slice.call(form.querySelectorAll(this._selector)).map((element) => {
if (element.dataset.format === undefined) {
console.log('Trying to bind litepicker to an element without data-format attribute');
}
options = {...options, ...{
format: element.dataset.format,
showTooltip: false,
element: element,
lang: LANGUAGE,
autoRefresh: true,
firstDay: FIRST_DOW, // Litepicker: 0 = Sunday, 1 = Monday
setup: (picker) => {
// nasty hack, because litepicker does not trigger change event on the input and the available
// event "selected" is triggered why to often, even when moving the cursor inside the input
// element (not even typing is necessary) and so we have to make sure that the manual "click" event
// (works for touch as well) happened before we actually dispatch the change event manually ...
// what? report forms would be submitted upon cursor move without the "preselect” check
picker.on('preselect', (date1, date2) => { // eslint-disable-line no-unused-vars
picker._wasPreselected = true;
});
picker.on('selected', (date1, date2) => { // eslint-disable-line no-unused-vars
if (picker._wasPreselected !== undefined) {
element.dispatchEvent(new Event('change', {bubbles: true}));
delete picker._wasPreselected;
}
});
},
}};
return [element, new Litepicker(this.prepareOptions(options))];
});
this._pickers = this._pickers.concat(newPickers);
}
prepareOptions(options)
{
return {...options, ...{
plugins: ['mobilefriendly'],
}};
}
/**
* @param {HTMLFormElement} form
*/
destroyForm(form)
{
[].slice.call(form.querySelectorAll(this._selector)).map((element) => {
for (let i = 0; i < this._pickers.length; i++) {
if (this._pickers[i][0] === element) {
this._pickers[i][1].destroy();
this._pickers.splice(i, 1);
}
}
});
}
}

View File

@@ -0,0 +1,25 @@
/*
* 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] KimaiDateRangePicker: activate the (daterange picker) compound field in toolbar
*/
import KimaiDatePicker from "./KimaiDatePicker";
export default class KimaiDateRangePicker extends KimaiDatePicker {
prepareOptions(options)
{
return {...options, ...{
plugins: ['mobilefriendly'],
singleMode: false,
autoRefresh: true,
}};
}
}

View File

@@ -0,0 +1,39 @@
/*
* 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] KimaiFormPlugin: base class for all none ID plugin that handle forms
*/
import KimaiPlugin from '../KimaiPlugin';
export default class KimaiFormPlugin extends KimaiPlugin {
/**
* @param {HTMLFormElement} form
* @return boolean
*/
supportsForm(form) // eslint-disable-line no-unused-vars
{
return false;
}
/**
* @param {HTMLFormElement} form
*/
activateForm(form) // eslint-disable-line no-unused-vars
{
}
/**
* @param {HTMLFormElement} form
*/
destroyForm(form) // eslint-disable-line no-unused-vars
{
}
}

View File

@@ -0,0 +1,574 @@
/*
* 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] KimaiFormSelect: enhanced functionality for HTMLSelectElement
*/
import TomSelect from 'tom-select';
import KimaiFormPlugin from "./KimaiFormPlugin";
export default class KimaiFormSelect extends KimaiFormPlugin {
constructor(selector, apiSelects)
{
super();
this._selector = selector;
this._apiSelects = apiSelects;
}
getId()
{
return 'form-select';
}
init()
{
// selects the original value inside dropdowns, as the "reset" event (the updated option)
// is not automatically propagated to the JS element
document.addEventListener('reset', (event) => {
if (event.target.tagName.toUpperCase() === 'FORM') {
setTimeout(() => {
const fields = event.target.querySelectorAll(this._selector);
for (let field of fields) {
if (field.tagName.toUpperCase() === 'SELECT') {
field.dispatchEvent(new Event('data-reloaded'));
}
}
}, 10);
}
});
}
/**
* @param {HTMLFormElement} node
*/
activateSelectPickerByElement(node)
{
let plugins = ['change_listener'];
const isMultiple = node.multiple !== undefined && node.multiple === true;
/*
const isOrdering = false;
if (isOrdering) {
plugins.push('caret_position');
plugins.push('drag_drop');
}
*/
if (isMultiple) {
plugins.push('remove_button');
}
let options = {
lockOptgroupOrder: true,
allowEmptyOption: true,
plugins: plugins,
// if there are more than X entries, the other ones are hidden and can only be found
// by typing some characters to trigger the internal option search
maxOptions: 500,
};
let render = {
option_create: (data, escape) => {
const name = escape(data.input);
if (name.length < 3) {
return null;
}
const tpl = this.translate('select.search.create');
const tplReplaced = tpl.replace('%input%', '<strong>' + name + '</strong>');
return '<div class="create">' + tplReplaced + '</div>';
},
no_results: (data, escape) => {
const tpl = this.translate('select.search.notfound');
const tplReplaced = tpl.replace('%input%', '<strong>' + escape(data.input) + '</strong>');
return '<div class="no-results">' + tplReplaced + '</div>';
},
onOptionAdd: (value) => {
node.dispatchEvent(new CustomEvent('create', {detail: {'value': value}}));
},
};
if (node.dataset['create'] !== undefined) {
options = {...options, ...{
persist: true,
create: true,
}};
} else {
options = {...options, ...{
persist: false,
create: false,
}};
}
if (node.dataset.disableSearch !== undefined) {
options = {...options, ...{
controlInput: null,
}};
}
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
options.render = {...render, ...{
option: function(data, escape) {
let color = data.value;
if (data.color !== undefined) {
color = data.color;
}
return '<div class="list-group-item border-0 p-1 ps-2 text-nowrap"><span style="background-color:' + color + '" class="color-choice-item">&nbsp;</span>' + escape(data.text) + '</div>';
},
item: function(data, escape) {
let color = data.value;
if (data.color !== undefined) {
color = data.color;
}
return '<div class="text-nowrap"><span style="background-color:' + color + '" class="color-choice-item">&nbsp;</span>' + escape(data.text) + '</div>';
}
}};
} else {
options.render = {...render, ...{
// the empty entry would collapse and only show as a tiny 5px line if there is no content inside
option: function(data, escape) {
let text = data.text;
if (text === null || text.trim() === '') {
text = '&nbsp;';
} else {
text = escape(text);
}
return '<div>' + text + '</div>';
}
}};
}
const select = new TomSelect(node, options);
node.addEventListener('data-reloaded', (event) => {
select.clear(true);
select.clearOptionGroups();
select.clearOptions();
select.sync();
select.setValue(event.detail);
select.refreshItems();
select.refreshOptions(false);
});
// support reloading the list upon external event
if (node.dataset['reload'] !== undefined) {
node.addEventListener('reload', () => {
select.disable();
node.disabled = true;
/** @type {KimaiAPI} API */
const API = this.getContainer().getPlugin('api');
API.get(node.dataset['reload'], {}, (data) => {
this._updateSelect(node, data);
select.enable();
node.disabled = false;
});
node.dispatchEvent(new Event('change'));
});
}
}
/**
* @param {HTMLFormElement} form
* @return boolean
*/
supportsForm(form) // eslint-disable-line no-unused-vars
{
return true;
}
/**
* @param {HTMLFormElement} form
*/
activateForm(form)
{
[].slice.call(form.querySelectorAll(this._selector)).map((node) => {
this.activateSelectPickerByElement(node);
});
this._activateApiSelects(this._apiSelects);
}
/**
* @param {HTMLFormElement} form
*/
destroyForm(form)
{
[].slice.call(form.querySelectorAll(this._selector)).map((node) => {
if (node.tomselect) {
node.tomselect.destroy();
}
});
}
/**
* @param {string|Element} selectIdentifier
* @param {object} data
* @private
*/
_updateOptions(selectIdentifier, data)
{
let emptyOption = null;
let node = null;
if (selectIdentifier instanceof Element) {
node = selectIdentifier;
} else {
node = document.querySelector(selectIdentifier);
}
if (node === null) {
console.log('Missing select: ' + selectIdentifier);
return;
}
const selectedValue = node.value;
for (let i = 0; i < node.options.length; i++) {
if (node.options[i].value === '') {
emptyOption = node.options[i];
}
}
node.options.length = 0;
if (emptyOption !== null) {
node.appendChild(this._createOption(emptyOption.text, ''));
}
let emptyOpts = [];
let options = [];
/** @type {string|null} titlePattern */
let titlePattern = null;
if (node.dataset !== undefined && node.dataset['optionPattern'] !== undefined) {
titlePattern = node.dataset['optionPattern'];
}
if (titlePattern === null || titlePattern === '') {
titlePattern = '{name}';
}
for (const [key, value] of Object.entries(data)) {
if (key === '__empty__') {
for (const entity of value) {
emptyOpts.push(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
}
continue;
}
let optGroup = this._createOptgroup(key);
for (const entity of value) {
optGroup.appendChild(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
}
options.push(optGroup);
}
options.forEach(child => node.appendChild(child));
emptyOpts.forEach(child => node.appendChild(child));
// if available, re-select the previous selected option (mostly usable for global activities)
node.value = selectedValue;
// pre-select an option if it is the only available one
if (node.value === '' || node.value === null) {
const allOptions = node.options;
const optionLength = allOptions.length;
let selectOption = '';
if (optionLength === 1) {
selectOption = allOptions[0].value;
} else if (optionLength === 2 && emptyOption !== null) {
selectOption = allOptions[1].value;
}
if (selectOption !== '') {
node.value = selectOption;
}
}
// this will update the attached javascript component
node.dispatchEvent(new CustomEvent('data-reloaded', {detail: node.value}));
// if we don't trigger the change, the other selects won't reset
node.dispatchEvent(new Event('change'));
}
/**
* @param {string} pattern
* @param {array} entity
* @private
*/
_getTitleFromPattern(pattern, entity)
{
const DATE_UTILS = this.getDateUtils();
const regexp = new RegExp('{[^}]*?}','g');
let title = pattern;
let match = null;
while ((match = regexp.exec(pattern)) !== null) {
// cutting a string like "{name}" into "name"
const field = match[0].slice(1, -1);
let value = entity[field] === undefined ? null : entity[field];
if ((field === 'start' || field === 'end')) {
if (value === null) {
value = '?';
} else {
value = DATE_UTILS.getFormattedDate(value);
}
}
title = title.replace(new RegExp('{' + field + '}', 'g'), value ?? '');
}
title = title.replace(/- \?-\?/, '');
title = title.replace(/\r\n|\r|\n/g, ' ');
title = title.substring(0, 110);
const chars = '- ';
let start = 0, end = title.length;
while (start < end && chars.indexOf(title[start]) >= 0) {
++start;
}
while (end > start && chars.indexOf(title[end - 1]) >= 0) {
--end;
}
return (start > 0 || end < title.length) ? title.substring(start, end) : title;
}
/**
* @param {HTMLSelectElement} select
* @param {string} label
* @param {string} value
* @param {object} dataset
*/
addOption(select, label, value, dataset)
{
const option = this._createOption(label, value);
for (const key in dataset) {
option.dataset[key] = dataset[key];
}
select.options.add(option);
if (select.tomselect !== undefined) {
select.tomselect.sync();
}
}
/**
*
* @param {HTMLSelectElement} select
* @param {HTMLOptionElement} option
*/
removeOption(select, option)
{
option.remove();
if (select.tomselect !== undefined) {
select.tomselect.removeOption(option.value, true);
select.tomselect.clear(true);
}
}
/**
* @param {string} label
* @param {string} value
* @returns {HTMLElement}
* @private
*/
_createOption(label, value)
{
let option = document.createElement('option');
option.innerText = label;
option.value = value;
return option;
}
/**
* @param {string} label
* @returns {HTMLElement}
* @private
*/
_createOptgroup(label)
{
let optGroup = document.createElement('optgroup');
optGroup.label = label;
return optGroup;
}
/**
* @param {string} selector
* @private
*/
_activateApiSelects(selector)
{
if (this._eventHandlerApiSelects === undefined) {
this._eventHandlerApiSelects = (event) => {
if (event.target === null || !event.target.matches(selector)) {
return;
}
const apiSelect = event.target;
const targetSelectId = '#' + apiSelect.dataset['relatedSelect'];
/** @type {HTMLSelectElement} targetSelect */
const targetSelect = document.getElementById(apiSelect.dataset['relatedSelect']);
// if the related target select does not exist, we do not need to load the related data
if (targetSelect === null) {
return;
}
if (targetSelect.tomselect !== undefined) {
targetSelect.tomselect.disable();
}
targetSelect.disabled = true;
let formPrefix = apiSelect.dataset['formPrefix'];
if (formPrefix === undefined || formPrefix === null) {
formPrefix = '';
} else if (formPrefix.length > 0) {
formPrefix += '_';
}
let newApiUrl = this._buildUrlWithFormFields(apiSelect.dataset['apiUrl'], formPrefix);
const selectValue = apiSelect.value;
// Problem: select a project with activities and then select a customer that has no project
// results in a wrong URL, it triggers "activities?project=" instead of using the "emptyUrl"
if (selectValue === undefined || selectValue === null || selectValue === '' || (Array.isArray(selectValue) && selectValue.length === 0)) {
if (apiSelect.dataset['emptyUrl'] === undefined) {
this._updateSelect(targetSelectId, {});
return;
}
newApiUrl = this._buildUrlWithFormFields(apiSelect.dataset['emptyUrl'], formPrefix);
}
/** @type {KimaiAPI} API */
const API = this.getContainer().getPlugin('api');
API.get(newApiUrl, {}, (data) => {
this._updateSelect(targetSelectId, data);
if (targetSelect.tomselect !== undefined) {
targetSelect.tomselect.enable();
}
targetSelect.disabled = false;
});
}
document.addEventListener('change', this._eventHandlerApiSelects);
}
}
/**
* @param {string} apiUrl
* @param {string} formPrefix
* @return {string}
* @private
*/
_buildUrlWithFormFields(apiUrl, formPrefix)
{
let newApiUrl = apiUrl;
apiUrl.split('?')[1].split('&').forEach(item => {
const [key, value] = item.split('='); // eslint-disable-line no-unused-vars
const decoded = decodeURIComponent(value);
const test = decoded.match(/%(.*)%/);
if (test !== null) {
const originalFieldName = test[1];
const targetFieldName = (formPrefix + originalFieldName).replace(/\[/, '').replace(/]/, '');
const targetField = document.getElementById(targetFieldName);
let newValue = '';
if (targetField === null) {
// happens for example:
// - in duration only mode, when the end field is not found
// console.log('ERROR: Cannot find field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
} else {
if (targetField.value !== null) {
newValue = targetField.value;
if (targetField.tagName === 'SELECT' && targetField.multiple) {
newValue = [...targetField.selectedOptions].map(o => o.value);
} else if (newValue !== '') {
if (targetField.type === 'date') {
const timeId = targetField.id.replace('_date', '_time')
const timeElement = document.getElementById(timeId);
const time = timeElement === null ? '12:00:00' : timeElement.value;
// using 12:00 as fallback, because timezone handling might change the date if we use 00:00
const newDate = this.getDateUtils().fromHtml5Input(newValue, time);
newValue = this.getDateUtils().formatForAPI(newDate, false);
} else if (targetField.type === 'text' && targetField.name.includes('date')) {
const timeId = targetField.id.replace('_date', '_time')
const timeElement = document.getElementById(timeId);
// using 12:00 as fallback, because timezone handling might change the date if we use 00:00
let time = '12:00:00';
let timeFormat = 'HH:mm';
if (timeElement !== null) {
time = timeElement.value;
timeFormat = timeElement.dataset['format'];
}
const newDate = this.getDateUtils().fromFormat(newValue.trim() + ' ' + time.trim(), targetField.dataset['format'] + ' ' + timeFormat);
newValue = this.getDateUtils().formatForAPI(newDate, false);
} else if (targetField.dataset['format'] !== undefined) {
// find out when this else branch is triggered and document!
if (this.getDateUtils().isValidDateTime(newValue, targetField.dataset['format'])) {
newValue = this.getDateUtils().format(targetField.dataset['format'], newValue);
}
}
} else {
// happens for example:
// - when the end date is not set on a timesheet record and the project list is loaded (as the URL contains the %end% replacer)
// console.log('Empty value found for field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
}
} else {
// happens for example:
// - when a customer without projects is selected
// console.log('ERROR: Empty field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
}
}
if (Array.isArray(newValue)) {
let urlParams = [];
for (let tmpValue of newValue) {
urlParams.push(originalFieldName + '=' + tmpValue);
}
newApiUrl = newApiUrl.replace(item, urlParams.join('&'));
} else {
newApiUrl = newApiUrl.replace(value, newValue);
}
}
});
return newApiUrl;
}
/**
* @param {string|Element} select
* @param {object} data
* @private
*/
_updateSelect(select, data)
{
const options = {};
for (const apiData of data) {
let title = '__empty__';
if (apiData['parentTitle'] !== undefined && apiData['parentTitle'] !== null) {
title = apiData['parentTitle'];
}
if (options[title] === undefined) {
options[title] = [];
}
options[title].push(apiData);
}
const ordered = {};
Object.keys(options).sort().forEach(function(key) {
ordered[key] = options[key];
});
this._updateOptions(select, ordered);
}
}

View File

@@ -0,0 +1,145 @@
/*
* 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] KimaiEditTimesheetForm: responsible for the most important form in the application
*/
import KimaiFormPlugin from "./KimaiFormPlugin";
import KimaiColor from "../widgets/KimaiColor";
export default class KimaiTeamForm extends KimaiFormPlugin {
init()
{
this.usersId = 'team_edit_form_users';
}
/**
* @param {HTMLFormElement} form
* @return boolean
*/
supportsForm(form)
{
return form.name === 'team_edit_form';
}
/**
* @return {HTMLElement}
* @private
*/
_getPrototype()
{
return document.getElementById('team_edit_form_members');
}
/**
* @param {HTMLFormElement} form
*/
activateForm(form)
{
if (!this.supportsForm(form)) {
return;
}
// must be attached to the form, because the button is added dynamically
form.addEventListener('click', event => this._removeMember(event));
document.getElementById(this.usersId).addEventListener('change', event => {
const select = event.target;
const option = select.options[select.selectedIndex];
const member = this._createMember(option);
this._getPrototype().append(member);
this.getPlugin('form-select').removeOption(select, option);
});
}
/**
* @param {HTMLOptionElement} option
* @returns {Element}
* @private
*/
_createMember(option)
{
const prototype = this._getPrototype();
let counter = prototype.dataset['widgetCounter'] || prototype.childNodes.length;
let newWidget = prototype.dataset['prototype'];
newWidget = newWidget.replace(/__name__/g, counter);
newWidget = newWidget.replace(/#000000/g, KimaiColor.calculateContrastColor(option.dataset.color));
newWidget = newWidget.replace(/__DISPLAY__/g, option.dataset.display);
newWidget = newWidget.replace(/__COLOR__/g, option.dataset.color);
newWidget = newWidget.replace(/__INITIALS__/g, option.dataset.initials);
newWidget = newWidget.replace(/__TITLE__/g, option.dataset.title);
newWidget = newWidget.replace(/__USERNAME__/g, option.text);
prototype.dataset['widgetCounter'] = (++counter).toString();
const temp = document.createElement('div');
temp.innerHTML = newWidget;
temp.querySelector('input[type=hidden]').value = option.value;
const newNode = temp.firstElementChild;
// copy over all initial settings, so we are able to rebuild the original option if the
// member is removed from the list later on
for (const key in option.dataset) {
newNode.dataset[key] = option.dataset[key];
}
return newNode;
}
/**
* @param {Event} event
* @private
*/
_removeMember(event)
{
let button = event.target;
if (button.parentNode.matches('.remove-member')) {
button = button.parentNode;
}
if (button.matches('.remove-member')) {
// see blocks.html.twig => block team_member_widget
const element = button.parentNode.parentNode.parentNode.parentNode.parentNode;
// re-adding the option to the select makes up for form validation errors
// because the list would have to be re-ordered and indices need to be changed ...
/*
this.getPlugin('form-select').addOption(
document.getElementById(this.usersId),
element.dataset['display'],
element.dataset['id'],
element.dataset
);
const prototype = this._getPrototype();
prototype.dataset['widgetCounter'] = (prototype.dataset['widgetCounter'] - 1).toString();
*/
element.remove();
event.stopPropagation();
event.preventDefault();
}
}
/**
* @param {HTMLFormElement} form
*/
destroyForm(form)
{
if (!this.supportsForm(form)) {
return;
}
form.removeEventListener('click', this._removeMember);
}
}

View File

@@ -0,0 +1,368 @@
/*
* 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] KimaiEditTimesheetForm: responsible for the most important form in the application
*/
import { DateTime } from 'luxon';
import KimaiFormPlugin from "./KimaiFormPlugin";
export default class KimaiTimesheetForm extends KimaiFormPlugin {
/**
* @param {HTMLFormElement} form
* @return boolean
*/
supportsForm(form)
{
return (form.name === 'timesheet_edit_form' || form.name ==='timesheet_admin_edit_form' || form.name ==='timesheet_multi_user_edit_form');
}
/**
* @param {HTMLFormElement} form
*/
destroyForm(form)
{
if (!this.supportsForm(form)) {
return;
}
if (this._beginDate !== undefined) {
this._beginDate.removeEventListener('change', this._beginListener);
delete this._beginListener;
delete this._beginDate;
}
if (this._beginTime !== undefined) {
this._beginTime.removeEventListener('change', this._beginListener);
delete this._beginTime;
}
if (this._endTime !== undefined) {
this._endTime.removeEventListener('change', this._endListener);
delete this._endTime;
}
if (this._duration !== undefined) {
this._duration.removeEventListener('change', this._durationListener);
delete this._durationListener;
delete this._duration;
}
if (this._durationToggle !== undefined && this._durationToggle !== null) {
this._durationToggle.removeEventListener('change', this._durationToggleListener);
delete this._durationToggleListener;
delete this._durationToggle;
}
if (this._activity !== undefined) {
this._activity.removeEventListener('create', this._activityListener);
delete this._activityListener;
delete this._activity;
}
if (this._project !== undefined) {
delete this._project;
}
}
activateForm(form)
{
if (!this.supportsForm(form)) {
return;
}
const formPrefix = form.name;
this._activity = document.getElementById(formPrefix + '_activity');
this._project = document.getElementById(formPrefix + '_project');
/** @param {CustomEvent} event */
this._activityListener = (event) => {
const project = this._project.value;
/** @type {KimaiAPI} API */
const API = this.getContainer().getPlugin('api');
API.post(this._activity.dataset['create'], {
name: event.detail.value,
project: (project === '' ? null : project),
visible: true,
}, () => {
this._project.dispatchEvent(new Event('change'));
});
};
this._activity.addEventListener('create', this._activityListener);
this._beginDate = document.getElementById(formPrefix + '_begin_date');
this._beginTime = document.getElementById(formPrefix + '_begin_time');
this._endTime = document.getElementById(formPrefix + '_end_time');
this._duration = document.getElementById(formPrefix + '_duration');
this._durationToggle = document.getElementById(formPrefix + '_duration_toggle');
if (this._beginDate === null || this._beginTime === null || this._endTime === null || this._duration === null) {
return;
}
this._beginListener = () => this._changedBegin();
this._endListener = () => this._changedEnd();
this._durationListener = () => this._changedDuration();
this._beginDate.addEventListener('change', this._beginListener);
this._beginTime.addEventListener('change', this._beginListener);
this._endTime.addEventListener('change', this._endListener);
this._duration.addEventListener('change', this._durationListener);
if (this._duration !== null && this._durationToggle !== null) {
this._durationToggleListener = () => {
this._durationToggle.classList.toggle('text-success');
};
this._durationToggle.addEventListener('click', this._durationToggleListener);
}
}
_isDurationConnected()
{
if (this._duration === null && this._durationToggle === null) {
return false;
}
if (this._durationToggle === null) {
return true;
}
return this._durationToggle.classList.contains('text-success');
}
/**
* @returns {DateTime|null}
* @private
*/
_getBegin()
{
if (this._beginDate.value === '' || this._beginTime.value === '') {
return null;
}
const date = this.getDateUtils().fromFormat(
this._beginDate.value + ' ' + this._beginTime.value,
this._beginDate.dataset['format'] + ' ' + this._beginTime.dataset['format'],
);
if (date.invalid) {
return null;
}
return date;
}
/**
* @returns {DateTime|null}
* @private
*/
_getEnd()
{
if (this._endTime.value === '') {
return null;
}
let date = this.getDateUtils().fromFormat(
DateTime.now().toFormat('yyyy-LL-dd') + ' ' + this._endTime.value,
'yyyy-LL-dd ' + this._endTime.dataset['format'],
);
const begin = this._getBegin();
if (begin !== null) {
date = this.getDateUtils().fromFormat(
begin.toFormat('yyyy-LL-dd') + ' ' + this._endTime.value,
'yyyy-LL-dd ' + this._endTime.dataset['format'],
);
if (date < begin) {
date = date.plus({days: 1});
}
}
if (date.invalid) {
return null;
}
return date;
}
/**
* Ruleset:
* - invalid begin => skip
* - empty end => set end to begin (only if duration > 0 = running record)
* - invalid end => skip
* - calculate duration
*/
_changedBegin()
{
const begin = this._getBegin();
if (begin === null) {
return;
}
const duration = this._getParsedDuration();
const hasDuration = duration.as('seconds') > 0;
const end = this._getEnd();
if (end === null && hasDuration) {
this._applyDateToField(begin.plus(duration), null, this._endTime);
} else {
this._updateDuration();
}
}
/**
* Ruleset:
* - invalid end => skip
* - empty begin => set begin to end
* - invalid begin => skip
* - calculate duration
*/
_changedEnd()
{
const end = this._getEnd();
// empty or invalid date => reset duration and stop progress
if (end === null) {
return;
}
const duration = this._getParsedDuration();
const hasDuration = duration.as('seconds') > 0;
const begin = this._getBegin();
if (begin === null && hasDuration) {
this._applyDateToField(end.minus(duration), this._beginDate, this._beginTime);
} else {
this._updateDuration();
}
}
/**
* @private
*/
_updateDuration()
{
const begin = this._getBegin();
const end = this._getEnd();
let newDuration = null;
if (begin !== null && end !== null) {
newDuration = end.diff(begin);
}
this._setDurationAsString(newDuration);
}
/**
* Ruleset:
* - invalid duration => skip
* - if begin and end are empty: set begin to now and end to duration
* - if begin is empty and end is not empty: set begin to end minus duration
* - if begin is not empty and end is empty and duration is > 0 (running records = 0): set end to begin plus duration
*/
_changedDuration()
{
if (!this._isDurationConnected()) {
return;
}
const duration = this._getParsedDuration();
if (!duration.isValid) {
this._setDurationAsString(null);
return;
}
const begin = this._getBegin();
let end = this._getEnd();
const seconds = duration.as('seconds');
if (seconds < 0) {
end = null;
}
if (begin === null && end === null) {
const newBegin = DateTime.now();
this._applyDateToField(newBegin, this._beginDate, this._beginTime);
this._applyDateToField(newBegin.plus({seconds: seconds}), null, this._endTime);
} else if (begin === null && end !== null) {
this._applyDateToField(end.minus({seconds: seconds}), this._beginDate, this._beginTime);
} else if (begin !== null && seconds > 0) {
this._applyDateToField(begin.plus({seconds: seconds}), null, this._endTime);
}
}
/**
* Writes the value of a duration object as human-readable string into the duration field
*
* @param {Duration|null} duration
*/
_setDurationAsString(duration)
{
if (!this._isDurationConnected()) {
return;
}
if (duration === null) {
this._duration.value = '';
return;
}
if (!duration.isValid) {
return;
}
const seconds = duration.as('seconds');
if (seconds < 0) {
this._duration.value = '';
return;
}
const hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds - (hours * 3600)) / 60);
if (minutes < 10) {
minutes = '0' + minutes;
}
this._duration.value = hours + ':' + minutes;
}
/**
* Returns a duration object from the duration input field.
*
* @private
* @return {Duration}
*/
_getParsedDuration()
{
return this.getDateUtils().parseDuration(this._duration.value.toUpperCase());
}
/**
* @param {DateTime|null} dateTime
* @param {HTMLElement|null} dateField
* @param {HTMLElement} timeField
* @private
*/
_applyDateToField(dateTime, dateField, timeField)
{
if (dateTime === null || dateTime.invalid) {
dateField.value = '';
timeField.value = '';
return;
}
if (dateField !== null) {
dateField.value = this.getDateUtils().format(dateField.dataset['format'], dateTime);
}
timeField.value = this.getDateUtils().format(timeField.dataset['format'], dateTime);
}
}

View File

@@ -9,7 +9,6 @@
* [KIMAI] KimaiAPI: easy access to API methods
*/
import jQuery from 'jquery';
import KimaiPlugin from "../KimaiPlugin";
export default class KimaiAPI extends KimaiPlugin {
@@ -18,135 +17,162 @@ export default class KimaiAPI extends KimaiPlugin {
return 'api';
}
_headers() {
const headers = new Headers();
headers.append('X-AUTH-SESSION', '1');
headers.append('Content-Type', 'application/json');
return headers;
}
get(url, data, callbackSuccess, callbackError) {
jQuery.ajax({
url: url,
headers: {
'X-AUTH-SESSION': true,
'Content-Type':'application/json'
},
if (data !== undefined) {
const params = (new URLSearchParams(data)).toString();
if (params !== '') {
url = url + (url.includes('?') ? '&' : '?') + params;
}
}
if (callbackError === undefined) {
callbackError = (error) => {
this.handleError('An error occurred', error);
};
}
this.fetch(url, {
method: 'GET',
data: data,
dataType: 'json',
success: callbackSuccess,
error: callbackError
headers: this._headers()
}).then((response) => {
response.json().then((json) => {
callbackSuccess(json);
});
}).catch((error) => {
callbackError(error);
});
}
post(url, data, callbackSuccess, callbackError) {
if (callbackError === null || callbackError === undefined) {
callbackError = this.getPostErrorHandler();
if (callbackError === undefined) {
callbackError = (error) => {
this.handleError('action.update.error', error);
};
}
jQuery.ajax({
url: url,
headers: {
'X-AUTH-SESSION': true,
'Content-Type':'application/json'
},
this.fetch(url, {
method: 'POST',
data: data,
dataType: 'json',
success: callbackSuccess,
error: callbackError
body: this._parseData(data),
headers: this._headers()
}).then((response) => {
response.json().then((json) => {
callbackSuccess(json);
});
}).catch((error) => {
callbackError(error);
});
}
patch(url, data, callbackSuccess, callbackError) {
if (callbackError === null || callbackError === undefined) {
callbackError = this.getPatchErrorHandler();
if (callbackError === undefined) {
callbackError = (error) => {
this.handleError('action.update.error', error);
};
}
jQuery.ajax({
url: url,
headers: {
'X-AUTH-SESSION': true,
'Content-Type':'application/json'
},
this.fetch(url, {
method: 'PATCH',
data: data,
dataType: 'json',
success: callbackSuccess,
error: callbackError
body: this._parseData(data),
headers: this._headers()
}).then((response) => {
if (response.statusCode === 204) {
callbackSuccess();
} else {
response.json().then((json) => {
callbackSuccess(json);
});
}
}).catch((error) => {
callbackError(error);
});
}
delete(url, callbackSuccess, callbackError) {
if (callbackError === null || callbackError === undefined) {
callbackError = this.getDeleteErrorHandler();
if (callbackError === undefined) {
callbackError = (error) => {
this.handleError('action.delete.error', error);
};
}
jQuery.ajax({
url: url,
headers: {
'X-AUTH-SESSION': true,
'Content-Type':'application/json'
},
this.fetch(url, {
method: 'DELETE',
dataType: 'json',
success: callbackSuccess,
error: callbackError
headers: this._headers()
}).then(() => {
callbackSuccess();
}).catch((error) => {
callbackError(error);
});
}
getDeleteErrorHandler() {
const self = this;
return function(xhr, err) {
self.handleError('action.delete.error', xhr, err);
};
}
/**
* @param {string|object} data
* @returns {string}
* @private
*/
_parseData(data) {
if (typeof data === 'object') {
return JSON.stringify(data);
}
getPatchErrorHandler() {
const self = this;
return function(xhr, err) {
self.handleError('action.update.error', xhr, err);
};
}
getPostErrorHandler() {
const self = this;
return function(xhr, err) {
self.handleError('action.update.error', xhr, err);
};
return data;
}
/**
* @param {string} message
* @param {jqXHR} xhr
* @param {string} err
* @param {Response} response
*/
handleError(message, xhr, err) {
let resultError = err;
if (xhr.responseJSON && xhr.responseJSON.message) {
resultError = xhr.responseJSON.message;
// find validation errors
if (xhr.status === 400 && xhr.responseJSON.errors) {
let collected = ['<u>' + resultError + '</u>'];
// form errors that are not attached to a field (like extra fields)
if (xhr.responseJSON.errors.errors) {
for (let error of xhr.responseJSON.errors.errors) {
collected.push(error);
handleError(message, response) {
if (response.headers === undefined) {
// this can happen if someone clicks to fast and auto running
// requests (e.g. active records) are aborted
return;
}
const contentType = response.headers.get("content-type");
if (contentType && contentType.indexOf("application/json") !== -1) {
response.json().then(data => {
let resultError = data.message;
// find validation errors
if (response.status === 400 && data.errors) {
let collected = ['<u>' + resultError + '</u>'];
// form errors that are not attached to a field (like extra fields)
if (data.errors.errors) {
for (let error of data.errors.errors) {
collected.push(error);
}
}
}
if (xhr.responseJSON.errors.children) {
for (let field in xhr.responseJSON.errors.children) {
let tmpField = xhr.responseJSON.errors.children[field];
if (tmpField.hasOwnProperty('errors') && tmpField.errors.length > 0) {
for (let error of tmpField.errors) {
collected.push(error);
if (data.errors.children) {
for (let field in data.errors.children) {
let tmpField = data.errors.children[field];
if (tmpField.errors !== undefined && tmpField.errors.length > 0) {
for (let error of tmpField.errors) {
collected.push(error);
}
}
}
}
if (collected.length > 0) {
resultError = collected;
}
}
if (collected.length > 0) {
resultError = collected;
}
}
} else if (xhr.status && xhr.statusText) {
resultError = '[' + xhr.status + '] ' + xhr.statusText;
}
this.getPlugin('alert').error(message, resultError);
this.getPlugin('alert').error(message, resultError);
});
} else {
response.text().then(() => {
const resultError = '[' + response.statusCode + '] ' + response.statusText;
this.getPlugin('alert').error(message, resultError);
});
}
}
}

View File

@@ -22,15 +22,14 @@ export default class KimaiAPILink extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
this._selector = selector;
}
init() {
const self = this;
document.addEventListener('click', function(event) {
document.addEventListener('click', (event) => {
let target = event.target;
while (target !== null && !target.matches('body')) {
if (target.classList.contains(self.selector)) {
while (target !== null && typeof target.matches === "function" && !target.matches('body')) {
if (target.classList.contains(this._selector)) {
const attributes = target.dataset;
let url = attributes['href'];
@@ -39,13 +38,13 @@ export default class KimaiAPILink extends KimaiPlugin {
}
if (attributes.question !== undefined) {
self.getContainer().getPlugin('alert').question(attributes.question, function(value) {
this.getContainer().getPlugin('alert').question(attributes.question, (value) => {
if (value) {
self._callApi(url, attributes);
this._callApi(url, attributes);
}
});
} else {
self._callApi(url, attributes);
this._callApi(url, attributes);
}
event.preventDefault();
@@ -57,37 +56,45 @@ export default class KimaiAPILink extends KimaiPlugin {
});
}
/**
* @param {string} url
* @param {DOMStringMap} attributes
* @private
*/
_callApi(url, attributes)
{
const method = attributes['method'];
const eventName = attributes['event'];
/** @type {KimaiAPI} API */
const API = this.getContainer().getPlugin('api');
const eventing = this.getContainer().getPlugin('event');
const alert = this.getContainer().getPlugin('alert');
const successHandle = function(result) {
eventing.trigger(eventName);
if (attributes.msgSuccess) {
alert.success(attributes.msgSuccess);
/** @type {KimaiEvent} EVENTS */
const EVENTS = this.getContainer().getPlugin('event');
/** @type {KimaiAlert} ALERT */
const ALERT = this.getContainer().getPlugin('alert');
const successHandle = () => {
EVENTS.trigger(eventName);
if (attributes['msgSuccess'] !== undefined) {
ALERT.success(attributes['msgSuccess']);
}
};
const errorHandle = function(xhr, err) {
const errorHandle = (error) => {
let message = 'action.update.error';
if (attributes.msgError) {
message = attributes.msgError;
if (attributes['msgError'] !== undefined) {
message = attributes['msgError'];
}
API.handleError(message, xhr, err);
API.handleError(message, error);
};
if (method === 'PATCH') {
let data = {};
if (attributes.payload) {
data = attributes.payload;
if (attributes['payload'] !== undefined) {
data = attributes['payload'];
}
API.patch(url, data, successHandle, errorHandle);
} else if (method === 'POST') {
let data = {};
if (attributes.payload) {
data = attributes.payload;
if (attributes['payload'] !== undefined) {
data = attributes['payload'];
}
API.post(url, data, successHandle, errorHandle);
} else if (method === 'DELETE') {

View File

@@ -15,8 +15,8 @@ export default class KimaiActiveRecords extends KimaiPlugin {
constructor(selector, selectorEmpty) {
super();
this.selector = selector;
this.selectorEmpty = selectorEmpty;
this._selector = selector;
this._selectorEmpty = selectorEmpty;
}
getId() {
@@ -24,101 +24,146 @@ export default class KimaiActiveRecords extends KimaiPlugin {
}
init() {
this.menu = document.querySelector(this.selector);
this._menu = document.querySelector(this._selector);
// the menu can be hidden if user has no permissions to see it
if (this.menu === null) {
if (this._menu === null) {
return;
}
this.attributes = this.menu.dataset;
this.attributes = this._menu.dataset;
const self = this;
const handle = function() { self.reloadActiveRecords(); };
const handleUpdate = () => {
this.reloadActiveRecords();
};
document.addEventListener('kimai.timesheetUpdate', handleUpdate);
document.addEventListener('kimai.timesheetDelete', handleUpdate);
document.addEventListener('kimai.activityUpdate', handleUpdate);
document.addEventListener('kimai.activityDelete', handleUpdate);
document.addEventListener('kimai.projectUpdate', handleUpdate);
document.addEventListener('kimai.projectDelete', handleUpdate);
document.addEventListener('kimai.customerUpdate', handleUpdate);
document.addEventListener('kimai.customerDelete', handleUpdate);
// -----------------------------------------------------------------------
// handle duration in the visible UI
this._updateBrowserTitle = !!this.getConfiguration('updateBrowserTitle');
this._updateDuration();
const handle = () => {
this._updateDuration();
};
this._updatesHandler = setInterval(handle, 10000);
document.addEventListener('kimai.timesheetUpdate', handle);
document.addEventListener('kimai.timesheetDelete', handle);
document.addEventListener('kimai.activityUpdate', handle);
document.addEventListener('kimai.activityDelete', handle);
document.addEventListener('kimai.projectUpdate', handle);
document.addEventListener('kimai.projectDelete', handle);
document.addEventListener('kimai.customerUpdate', handle);
document.addEventListener('kimai.customerDelete', handle);
document.addEventListener('kimai.reloadedContent', handle);
}
_toggleMenu(hasEntries) {
this.menu.style.display = hasEntries ? 'inline-block' : 'none';
// TODO we could unregister all handler and listener
// _unregisterHandler() {
// clearInterval(this._updatesHandler);
// }
_updateDuration() {
const activeRecords = this._menu.querySelectorAll('[data-since]:not([data-since=""])');
if (activeRecords.length === 0) {
if (this._updateBrowserTitle) {
if (document.body.dataset['title'] === undefined) {
this._updateBrowserTitle = false;
} else {
document.title = document.body.dataset['title'];
}
}
return;
}
const DATE = this.getDateUtils();
let durations = [];
for (const record of activeRecords) {
const duration = DATE.formatDuration(record.dataset['since']);
// only use the ones from the menu for the title
if (record.dataset['replacer'] !== undefined && record.dataset['title'] !== null && duration !== '?') {
durations.push(duration);
}
// but update all on the page (running entries in list pages)
record.textContent = duration;
}
if (durations.length === 0) {
return;
}
if (!this._updateBrowserTitle) {
return;
}
let title = durations.shift();
for (const duration of durations.slice(0, 2)) {
title += ' | ' + duration;
}
document.title = title;
}
_setEntries(entries) {
const hasEntries = entries.length > 0;
this._menu.style.display = hasEntries ? 'inline-block' : 'none';
if (!hasEntries) {
// make sure that template entries in the menu are removed, otherwise they
// might still be shown in the browsers title
for (let record of this.menu.querySelectorAll('[data-since]')) {
for (let record of this._menu.querySelectorAll('[data-since]')) {
record.dataset['since'] = '';
}
}
const menuEmpty = document.querySelector(this.selectorEmpty);
const menuEmpty = document.querySelector(this._selectorEmpty);
if (menuEmpty !== null) {
menuEmpty.style.display = !hasEntries ? 'inline-block' : 'none';
}
}
setEntries(entries) {
this._toggleMenu(entries.length > 0);
const stop = this._menu.querySelector('.ticktac-stop');
const template = this.menu.querySelector('[data-template="active-record"]');
const label = this.menu.querySelector('a > span.label');
if (label !== null) {
label.innerText = entries.length === 0 ? '' : entries.length;
}
if (entries.length === 0) {
if (!hasEntries) {
if (stop) {
stop.accesskey = null;
}
return;
}
if (template === null) {
this._replaceInNode(this.menu, entries[0]);
} else {
const container = template.parentElement;
container.innerHTML = '';
for (let timesheet of entries) {
const newNode = template.cloneNode(true);
container.appendChild(this._replaceInNode(newNode, timesheet));
}
if (stop) {
stop.accesskey = 's';
}
this.getContainer().getPlugin('timesheet-duration').updateRecords();
this._replaceInNode(this._menu, entries[0]);
this._updateDuration();
}
_replaceInNode(node, timesheet) {
const date = this.getContainer().getPlugin('date');
const date = this.getDateUtils();
const allReplacer = node.querySelectorAll('[data-replacer]');
for (let node of allReplacer) {
const replacerName = node.dataset['replacer'];
for (let link of allReplacer) {
const replacerName = link.dataset['replacer'];
if (replacerName === 'url') {
node.href = this.attributes['href'].replace('000', timesheet.id);
link.href = this.attributes['href'].replace('000', timesheet.id);
} else if (replacerName === 'activity') {
node.innerText = timesheet.activity.name;
link.innerText = timesheet.activity.name;
} else if (replacerName === 'project') {
node.innerText = timesheet.project.name;
link.innerText = timesheet.project.name;
} else if (replacerName === 'customer') {
node.innerText = timesheet.project.customer.name;
link.innerText = timesheet.project.customer.name;
} else if (replacerName === 'duration') {
node.dataset['since'] = timesheet.begin;
node.innerText = date.formatDuration(timesheet.duration);
link.dataset['since'] = timesheet.begin;
link.innerText = date.formatDuration(timesheet.duration);
}
}
return node;
}
reloadActiveRecords() {
const self = this;
const API= this.getContainer().getPlugin('api');
/** @type {KimaiAPI} API */
const API = this.getContainer().getPlugin('api');
API.get(this.attributes['api'], {}, function(result) {
self.setEntries(result);
API.get(this.attributes['api'], {}, (result) => {
this._setEntries(result);
});
}

View File

@@ -1,78 +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] KimaiActiveRecordsDuration: activate the updates for all active timesheet records on this page
*/
import KimaiPlugin from '../KimaiPlugin';
export default class KimaiActiveRecordsDuration extends KimaiPlugin {
getId() {
return 'timesheet-duration';
}
init() {
this.updateBrowserTitle = !!this.getConfiguration('updateBrowserTitle');
this.updateRecords();
const self = this;
const handle = function() { self.updateRecords(); };
this._updatesHandler = setInterval(handle, 10000);
// this will probably not work as expected, as other event-handler might need longer to update the DOM
document.addEventListener('kimai.timesheetUpdate', handle);
}
unregisterUpdates() {
clearInterval(this._updatesHandler);
}
updateRecords() {
let durations = [];
const activeRecords = document.querySelectorAll('[data-since]:not([data-since=""])');
if (activeRecords.length === 0) {
if (this.updateBrowserTitle) {
if (document.body.dataset['title'] === undefined) {
this.updateBrowserTitle = false;
} else {
document.title = document.body.dataset['title'];
}
}
return;
}
const DATE = this.getPlugin('date');
for (let record of activeRecords) {
const since = record.dataset['since'];
const duration = DATE.formatDuration(since);
// only use the ones from the menu for the title
if (record.dataset['replacer'] !== undefined && record.dataset['title'] !== null && duration !== '?') {
durations.push(duration);
}
// but update all on the page (running entries in list pages)
record.textContent = duration;
}
if (durations.length === 0) {
return;
}
if (!this.updateBrowserTitle) {
return;
}
let title = durations.shift();
let prefix = ' | ';
for (let duration of durations.slice(0, 2)) {
title += prefix + duration;
}
document.title = title;
}
}

View File

@@ -12,70 +12,87 @@
* opening a modal with the content from the URL given in the elements 'data-href' or 'href' attribute
*/
import jQuery from 'jquery';
import KimaiReducedClickHandler from "./KimaiReducedClickHandler";
import { Modal } from 'bootstrap';
export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
constructor(selector) {
super();
this.selector = selector;
this._selector = selector;
}
getId() {
getId()
{
return 'modal';
}
init() {
const self = this;
this.isDirty = false;
init()
{
this._isDirty = false;
this.modal = jQuery('#remote_form_modal');
this.modal
.on('hide.bs.modal', function (e) {
if (self.isDirty) {
if (jQuery('#remote_form_modal .modal-body .remote_modal_is_dirty_warning').length === 0) {
const msg = self.getContainer().getTranslation().get('modal.dirty');
jQuery('#remote_form_modal .modal-body').prepend('<p class="'+(self.modal.hasClass('modal-danger') ? 'well well-sm ' : '') + 'text-danger small remote_modal_is_dirty_warning">' + msg + '</p>');
}
e.preventDefault();
return;
const modalElement = this._getModalElement();
if (modalElement === null) {
return;
}
modalElement.addEventListener('hide.bs.modal', (event) => {
if (this._isDirty) {
if (modalElement.querySelector('.modal-body .remote_modal_is_dirty_warning') === null) {
const msg = this.translate('modal.dirty');
const temp = document.createElement('div');
temp.innerHTML = '<p class="text-danger small remote_modal_is_dirty_warning">' + msg + '</p>';
modalElement.querySelector('.modal-body').prepend(temp.firstElementChild);
}
jQuery(self._getFormIdentifier()).off('change', self._isDirtyHandler);
self.isDirty = false;
self.getContainer().getPlugin('event').trigger('modal-hide');
})
.on('hidden.bs.modal', function () {
// kill all references, so GC can kick in
self.getContainer().getPlugin('form').destroyForm(self._getFormIdentifier());
jQuery('#remote_form_modal .modal-body').replaceWith('');
})
.on('show.bs.modal', function () {
self.getContainer().getPlugin('event').trigger('modal-show');
});
event.preventDefault();
return;
}
this._isDirty = false;
document.dispatchEvent(new Event('modal-hide'));
});
this._addClickHandler(this.selector, function(href) {
self.openUrlInModal(href);
modalElement.addEventListener('hidden.bs.modal', () => {
// kill all references, so GC can kick in
this.getContainer().getPlugin('form').destroyForm(this._getFormIdentifier());
modalElement.querySelector('.modal-body').replaceWith('');
});
modalElement.addEventListener('show.bs.modal', () => {
document.dispatchEvent(new Event('modal-show'));
});
this.addClickHandler(this._selector, (href) => {
this.openUrlInModal(href);
});
}
openUrlInModal(url, errorHandler) {
const self = this;
_getModal()
{
return Modal.getOrCreateInstance(this._getModalElement())
}
if (errorHandler === undefined) {
errorHandler = function(xhr, err) {
if (xhr.status === undefined || xhr.status !== 403) {
window.location = url;
}
};
}
openUrlInModal(url)
{
const headers = new Headers();
headers.append('X-Requested-With', 'Kimai-Modal');
jQuery.ajax({
url: url,
success: function(html) {
self._openFormInModal(html);
},
error: errorHandler
this.fetch(url, {
method: 'GET',
redirect: 'follow',
headers: headers
})
.then(response => {
if (!response.ok) {
window.location = url;
return;
}
return response.text().then(html => {
this._openFormInModal(html);
});
})
.catch(() => {
window.location = url;
});
}
@@ -85,157 +102,185 @@ export default class KimaiAjaxModalForm extends KimaiReducedClickHandler {
* @returns {string}
* @private
*/
_getFormIdentifier() {
_getFormIdentifier()
{
return '#remote_form_modal .modal-content form';
}
_openFormInModal(html) {
const self = this;
/**
* @returns {HTMLElement|null}
* @private
*/
_getModalElement()
{
return document.getElementById('remote_form_modal');
}
let formIdentifier = this._getFormIdentifier();
// if any of these is found in a response, the form will be re-displayed
let flashErrorIdentifier = 'div.alert-error';
// messages to show above the form
let flashMessageIdentifier = 'div.alert';
let form = jQuery(formIdentifier);
let remoteModal = this.modal;
/**
* @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);
}
}
// will be (re-)activated later
form.off('submit');
return node;
}
_openFormInModal(html)
{
const formIdentifier = this._getFormIdentifier();
let remoteModal = this._getModalElement();
const newFormHtml = document.createElement('div');
newFormHtml.innerHTML = html;
const newModalContent = this._makeScriptExecutable(newFormHtml.querySelector('#form_modal .modal-content'));
// load new form from given content
if (jQuery(html).find('#form_modal .modal-content').length > 0) {
// Support changing modal importance/types
remoteModal.on('hidden.bs.modal', function () {
if (remoteModal.hasClass('modal-danger')) {
remoteModal.removeClass('modal-danger');
}
});
if (jQuery(html).find('#form_modal').hasClass('modal-danger')) {
remoteModal.addClass('modal-danger');
}
if (newModalContent !== null) {
// Support changing modal sizes
let modalDialog = remoteModal.find('.modal-dialog');
let largeModal = jQuery(html).find('.modal-dialog').hasClass('modal-lg');
if (largeModal && !modalDialog.hasClass('modal-lg')) {
modalDialog.addClass('modal-lg');
}
if (!largeModal && modalDialog.hasClass('modal-lg')) {
modalDialog.removeClass('modal-lg');
let modalDialog = remoteModal.querySelector('.modal-dialog');
let largeModal = newFormHtml.querySelector('.modal-dialog').classList.contains('modal-lg');
if (largeModal && !modalDialog.classList.contains('modal-lg')) {
modalDialog.classList.toggle('modal-lg');
}
jQuery('#remote_form_modal .modal-content').replaceWith(
jQuery(html).find('#form_modal .modal-content')
);
if (!largeModal && modalDialog.classList.contains('modal-lg')) {
modalDialog.classList.toggle('modal-lg');
}
jQuery('#remote_form_modal [data-dismiss=modal]').on('click', function() {
self.isDirty = false;
remoteModal.querySelector('.modal-content').replaceWith(newModalContent);
[].slice.call(remoteModal.querySelectorAll('[data-bs-dismiss="modal"]')).map((element) => {
element.addEventListener('click', () => {
this._isDirty = false;
this._getModal().hide();
});
});
// activate new loaded widgets
self.getContainer().getPlugin('form').activateForm(formIdentifier);
this.getContainer().getPlugin('form').activateForm(formIdentifier);
}
// show error flash messages
let flashMessages = jQuery(html).find(flashMessageIdentifier);
if (flashMessages.length > 0) {
jQuery('#remote_form_modal .modal-body').prepend(flashMessages);
let flashMessages = newFormHtml.querySelector('div.alert');
if (flashMessages !== null) {
remoteModal.querySelector('.modal-body').prepend(flashMessages);
}
// -----------------------------------------------------------------------
// a fix for firefox focus problems with datepicker in modal
// see https://github.com/kimai/kimai/issues/618
let enforceModalFocusFn = jQuery.fn.modal.Constructor.prototype.enforceFocus;
jQuery.fn.modal.Constructor.prototype.enforceFocus = function() {};
remoteModal.on('hidden.bs.modal', function () {
jQuery.fn.modal.Constructor.prototype.enforceFocus = enforceModalFocusFn;
});
// -----------------------------------------------------------------------
remoteModal.modal('show');
// the new form that was loaded via ajax
form = jQuery(formIdentifier);
const form = document.querySelector(formIdentifier);
this._isDirtyHandler = function(e) {
self.isDirty = true;
}
form.on('change', this._isDirtyHandler);
form.addEventListener('change', () => {
this._isDirty = true;
});
// click handler for modal save button, to send forms via ajax
form.on('submit', function(event) {
// if the form has a target, we let the normal HTML flow happen
if (form.attr('target') !== undefined) {
return true;
}
form.addEventListener('submit', this._getEventHandler());
// otherwise we do some AJAX magic to process the form in the background
const btn = jQuery(formIdentifier + ' button[type=submit]').button('loading');
const eventName = form.attr('data-form-event');
const events = self.getContainer().getPlugin('event');
const alert = self.getContainer().getPlugin('alert');
this._getModal().show();
}
event.preventDefault();
event.stopPropagation();
_getEventHandler()
{
if (this.eventHandler === undefined) {
this.eventHandler = (event) => {
const form = event.target;
jQuery.ajax({
url: form.attr('action'),
type: form.attr('method'),
data: form.serialize(),
success: function(html) {
btn.button('reset');
let hasFieldError = jQuery(html).find('#form_modal .modal-content .has-error').length > 0;
let hasFormError = jQuery(html).find('#form_modal .modal-content ul.list-unstyled li.text-danger').length > 0;
let hasFlashError = jQuery(html).find(flashErrorIdentifier).length > 0;
if (hasFieldError || hasFormError || hasFlashError) {
self._openFormInModal(html);
} else {
events.trigger(eventName);
// try to find form defined messages first ...
let msg = form.attr('data-msg-success');
if (msg === null || msg === undefined) {
// ... but if none was available, check the response to find server rendered flash-message
let flashMessage = jQuery(html).find('section.content div.row div.alert.alert-success');
if (flashMessage.length > 0) {
let flashContent = flashMessage.contents();
if (flashContent.length === 3) {
msg = flashContent[2].textContent;
}
}
}
// ... and if even that is not available, we use a generic fallback message
if (msg === null || msg === undefined) {
msg = 'action.update.success';
}
self.isDirty = false;
remoteModal.modal('hide');
alert.success(msg);
}
return false;
},
error: function(xhr, err) {
let message = form.attr('data-msg-error');
if (message === null || message === undefined) {
message = 'action.update.error';
}
if (xhr.responseJSON && xhr.responseJSON.message) {
err = xhr.responseJSON.message;
} else if (xhr.status && xhr.statusText) {
err = '[' + xhr.status +'] ' + xhr.statusText;
}
alert.error(message, err);
// this is useful for changing form fields and retrying to save (and in development to test form changes)
setTimeout(function() {
btn.button('reset');
}, 1500);
// if the form has a target, we let the normal HTML flow happen
if (form.target !== undefined && form.target !== '') {
return true;
}
});
});
// otherwise we do some AJAX magic to process the form in the background
/** @type {HTMLButtonElement} btn */
const btn = document.querySelector(this._getFormIdentifier() + ' button[type=submit]');
btn.textContent = btn.textContent + ' …';
btn.disabled = true;
const eventName = form.dataset['formEvent'];
/** @type {KimaiEvent} alert */
const events = this.getContainer().getPlugin('event');
/** @type {KimaiAlert} alert */
const alert = this.getContainer().getPlugin('alert');
event.preventDefault();
event.stopPropagation();
const headers = new Headers();
headers.append('X-Requested-With', 'Kimai-Modal');
const options = {headers: headers};
this.fetchForm(form, options)
.then(response => {
response.text().then((html) => {
/** @type {HTMLDivElement} responseHtml */
const responseHtml = document.createElement('div');
responseHtml.innerHTML = html;
let hasFieldError = false;
let hasFormError = false;
let hasFlashError = false;
// button must be re-enabled anyway
btn.textContent = btn.textContent.replace(' …', '');
btn.disabled = false;
// if the request was successful, there will be no form
/** @type {Element} modalContent */
const modalContent = responseHtml.querySelector('#form_modal .modal-content');
if (modalContent !== null) {
hasFieldError = modalContent.querySelector('.is-invalid') !== null;
if (!hasFieldError) {
// happens when an error occurs for a "hidden or non-classical" form element e.g. creating team without users
hasFieldError = modalContent.querySelector('.invalid-feedback') !== null;
}
hasFormError = modalContent.querySelector('ul.list-unstyled li.text-danger') !== null;
hasFlashError = responseHtml.querySelector('div.alert-danger') !== null;
}
if (hasFieldError || hasFormError || hasFlashError) {
this._openFormInModal(html);
} else {
events.trigger(eventName);
// try to find form defined message first, but
let msg = form.dataset['msgSuccess'];
// if that is not available: use a generic fallback message
if (msg === null || msg === undefined || msg === '') {
msg = 'action.update.success';
}
this._isDirty = false;
this._getModal().hide();
alert.success(msg);
}
});
})
.catch(error => {
let message = form.dataset['msgError'];
if (message === null || message === undefined || message === '') {
message = 'action.update.error';
}
alert.error(message, error.message);
// this is useful for changing form fields and retrying to save (and in development to test form changes)
setTimeout(() =>{
// critical error, allow to re-submit?
btn.textContent = btn.textContent.replace(' …', '');
btn.disabled = false;
}, 1500);
});
};
}
return this.eventHandler;
}
}

View File

@@ -9,108 +9,261 @@
* [KIMAI] KimaiAlert: notifications for Kimai
*/
import Swal from 'sweetalert2'
import KimaiPlugin from "../KimaiPlugin";
import {Modal, Toast} from "bootstrap";
export default class KimaiAlert extends KimaiPlugin {
/**
* @return {string}
*/
getId() {
return 'alert';
}
/**
* @param {string} title
* @param {string|array} message
* @param {string|array|undefined} message
*/
error(title, message) {
const translation = this.getContainer().getTranslation();
const translation = this.getTranslation();
if (translation.has(title)) {
title = translation.get(title);
}
if (translation.has(message)) {
message = translation.get(message);
title = title.replace('%reason%', '');
if (message === undefined) {
message = null;
}
if (Array.isArray(message)) {
Swal.fire({
icon: 'error',
title: title.replace('%reason%', ''),
html: message.join('<br>'),
});
} else {
Swal.fire({
icon: 'error',
title: title.replace('%reason%', ''),
text: message,
});
if (message !== null) {
if (translation.has(message)) {
message = translation.get(message);
}
if (Array.isArray(message)) {
message = message.join('<br>');
}
}
const id = 'alert_global_error';
const oldModalElement = document.getElementById(id);
if (oldModalElement !== null) {
Modal.getOrCreateInstance(oldModalElement).hide();
}
const html = `
<div class="modal modal-blur fade" id="` + id + `" tabindex="-1" role="dialog">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-status bg-` + this._mapClass('danger') + `"></div>
<div class="modal-body text-center py-4">
<i class="fas fa-exclamation-circle fa-3x mb-3 text-danger"></i>
<h2>` + title + `</h2>
` + (message !== null ? '<div class="text-muted">' + message + '</div>' : '') + `
</div>
<div class="modal-footer">
<div class="w-100">
<div class="row">
<div class="col text-center"><a href="#" class="btn btn-primary" data-bs-dismiss="modal">` + translation.get('close') + `</a></div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
this._showModal(html);
}
/**
* @param {string} message
*/
warning(message) {
this._show('warning', message);
}
/**
* @param {string} message
*/
success(message) {
this._toast('success', message);
}
/**
* @param {string} message
*/
info(message) {
this._show('info', message);
}
_show(type, message) {
const translation = this.getContainer().getTranslation();
/**
* @param {string} html
* @private
*/
_showModal(html) {
const container = document.body;
const template = document.createElement('template');
template.innerHTML = html.trim();
const element = template.content.firstChild;
container.appendChild(element);
if (translation.has(message)) {
message = translation.get(message);
}
Swal.fire({
icon: type,
title: message,
});
}
_toast(type, message) {
const translation = this.getContainer().getTranslation();
if (translation.has(message)) {
message = translation.get(message);
}
Swal.fire({
timer: 2000,
timerProgressBar: true,
toast: true,
position: 'top',
showConfirmButton: false,
icon: type,
title: message,
const modal = new Modal(element);
element.addEventListener('hidden.bs.modal', function () {
container.removeChild(element);
});
modal.show();
}
/**
* Callback receives a value and needs to decide what should happen with it
* @param {string} type
* @param {string} message
* @private
*/
_show(type, message) {
const translation = this.getTranslation();
if (translation.has(message)) {
message = translation.get(message);
}
const html = `
<div class="modal modal-blur fade" tabindex="-1" role="dialog">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-status bg-` + this._mapClass(type) + `"></div>
<div class="modal-body text-center py-4">
<i class="fas fa-exclamation-circle fa-3x mb-3 text-` + this._mapClass(type) + `"></i>
<h2>` + message + `</h2>
</div>
<div class="modal-footer">
<div class="w-100">
<div class="row">
<div class="col text-center"><a href="#" class="btn btn-primary" data-bs-dismiss="modal">` + translation.get('close') + `</a></div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
this._showModal(html);
}
/**
* @param {string} type
* @return {string}
* @private
*/
_mapClass(type) {
if (type === 'info' || type === 'success' || type === 'warning' || type === 'danger') {
return type;
} else if (type === 'error') {
return 'danger';
}
return 'primary';
}
/**
* @param type
* @param message
* @private
*/
_toast(type, message) {
const translation = this.getTranslation();
if (translation.has(message)) {
message = translation.get(message);
}
let icon = '<i class="fas fa-info me-2"></i>';
if (type === 'success') {
icon = '<i class="fas fa-check me-2"></i>';
} else if (type === 'warning') {
icon = '<i class="fas fa-exclamation me-2"></i>';
} else if (type === 'danger' || type === 'error') {
icon = '<i class="fas fa-exclamation-circle me-2"></i>';
}
const html =
`<div class="toast align-items-center text-white bg-` + this._mapClass(type) + ` border-0" data-bs-delay="2000" role="alert" aria-live="assertive" aria-atomic="true">
<div class="d-flex">
<div class="toast-body">
` + icon + ' ' + message + `
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="` + translation.get('close') + `"></button>
</div>
</div>`;
const container = document.getElementById('toast-container');
const template = document.createElement('template');
template.innerHTML = html.trim();
const element = template.content.firstChild;
container.appendChild(element);
const toast = new Toast(element);
element.addEventListener('hidden.bs.toast', function () {
container.removeChild(element);
})
toast.show();
}
/**
* Callback receives a bool value (true = confirm, false = cancel / close without action).
*
* @param message
* @param callback
*/
question(message, callback) {
const translation = this.getContainer().getTranslation();
const translation = this.getTranslation();
if (translation.has(message)) {
message = translation.get(message);
}
Swal.fire({
title: message,
icon: 'question',
showCancelButton: true,
confirmButtonText: translation.get('confirm'),
cancelButtonText: translation.get('cancel')
}).then((result) => {
callback(result.value);
});
}
const css = this._mapClass('info');
const html = `
<div class="modal modal-blur fade" tabindex="-1" role="dialog" data-bs-backdrop="static">
<div class="modal-dialog modal-sm modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-status bg-` + css + `"></div>
<div class="modal-body text-center py-4">
<i class="fas fa-question fa-3x mb-3 text-` + css + `"></i>
<h2>` + message + `</h2>
</div>
<div class="modal-footer">
<div class="w-100">
<div class="row">
<div class="col"><a href="#" class="question-confirm btn btn-primary w-100" data-bs-dismiss="modal">` + translation.get('confirm') + `</a></div>
<div class="col"><a href="#" class="question-cancel btn w-100" data-bs-dismiss="modal">` + translation.get('cancel') + `</a></div>
</div>
</div>
</div>
</div>
</div>
</div>
`;
const container = document.body;
const template = document.createElement('template');
template.innerHTML = html.trim();
const element = template.content.firstChild;
container.appendChild(element);
element.querySelector('.question-confirm').addEventListener('click', () => {
callback(true);
});
element.querySelector('.question-cancel').addEventListener('click', () => {
callback(false);
});
const modal = new Modal(element);
element.addEventListener('hidden.bs.modal', () => {
container.removeChild(element);
});
modal.show();
}
}

View File

@@ -12,18 +12,17 @@
* redirecting to the URL given in the elements 'data-href' or 'href' attribute
*/
import jQuery from 'jquery';
import KimaiReducedClickHandler from "./KimaiReducedClickHandler";
export default class KimaiAlternativeLinks extends KimaiReducedClickHandler {
constructor(selector) {
super();
this.selector = selector;
this._selector = selector;
}
init() {
this._addClickHandler(this.selector, function(href) {
this.addClickHandler(this._selector, function(href) {
window.location = href;
});
}

View File

@@ -1,106 +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.
*/
import jQuery from 'jquery';
import KimaiPlugin from "../KimaiPlugin";
/**
* Supporting auto-complete fields via API.
* Currently used for timesheet tagging in toolbar and edit dialogs.
*/
export default class KimaiAutocomplete extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
init() {
this.minChars = this.getConfiguration('autoComplete');
}
getId() {
return 'autocomplete';
}
splitTagList(val) {
return val.split(/,\s*/);
}
extractLastTag(term) {
return this.splitTagList(term).pop();
}
activateAutocomplete(selector) {
const self = this;
jQuery(selector + ' ' + this.selector).each(function(index) {
const currentField = jQuery(this);
const apiUrl = currentField.attr('data-autocomplete-url');
const API = self.getContainer().getPlugin('api');
currentField
// don't navigate away from the field on tab when selecting an item
.on("keydown", function (event) {
if (event.keyCode === jQuery.ui.keyCode.TAB &&
jQuery(this).autocomplete("instance").menu.active) {
event.preventDefault();
}
})
.autocomplete({
source: function (request, response) {
const lastEntry = self.extractLastTag(request.term);
API.get(apiUrl, {'name': lastEntry}, function(data){
response(data);
});
},
search: function () {
// custom minLength
var term = self.extractLastTag(this.value);
if (term.length < self.minChars) {
return false;
}
},
focus: function () {
// prevent value inserted on focus
return false;
},
select: function (event, ui) {
var terms = self.splitTagList(this.value);
// remove the current input
terms.pop();
// check if selected tag is already in list
if (!terms.includes(ui.item.value)) {
// add the selected item
terms.push(ui.item.value);
}
// add placeholder to get the comma-and-space at the end
terms.push("");
this.value = terms.join(", ");
$(this).trigger('change');
return false;
}
}
)
;
});
}
destroyAutocomplete(selector) {
jQuery(selector + ' ' + this.selector).each(function(index) {
const currentField = jQuery(this);
currentField.autocomplete("destroy");
currentField.removeData('autocomplete');
});
}
}

View File

@@ -19,15 +19,14 @@ export default class KimaiConfirmationLink extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
this._selector = selector;
}
init() {
const self = this;
document.addEventListener('click', function(event) {
document.addEventListener('click', (event) => {
let target = event.target;
while (target !== null && !target.matches('body')) {
if (target.classList.contains(self.selector)) {
while (target !== null && typeof target.matches === "function" && !target.matches('body')) {
if (target.classList.contains(this._selector)) {
const attributes = target.dataset;
// is this a link?
@@ -44,7 +43,7 @@ export default class KimaiConfirmationLink extends KimaiPlugin {
}
if (attributes.question !== undefined) {
self.getContainer().getPlugin('alert').question(attributes.question, function(value) {
this.getContainer().getPlugin('alert').question(attributes.question, function(value) {
if (value) {
if (form === null) {
document.location = url;

View File

@@ -9,15 +9,15 @@
* [KIMAI] KimaiDatatable: handles functionality for the datatable
*/
import jQuery from 'jquery';
import KimaiPlugin from "../KimaiPlugin";
import KimaiContextMenu from "../widgets/KimaiContextMenu";
export default class KimaiDatatable extends KimaiPlugin {
constructor(contentAreaSelector, tableSelector) {
super();
this.contentArea = contentAreaSelector;
this.selector = tableSelector;
this._contentArea = contentAreaSelector;
this._selector = tableSelector;
}
getId() {
@@ -25,24 +25,21 @@ export default class KimaiDatatable extends KimaiPlugin {
}
init() {
const dataTable = document.querySelector(this.selector);
const dataTable = document.querySelector(this._selector);
// not every page contains a dataTable
if (dataTable === null) {
return;
}
const attributes = dataTable.dataset;
const events = attributes['reloadEvent'];
this.fixDropdowns();
this.registerContextMenu(this._selector);
const events = dataTable.dataset['reloadEvent'];
if (events === undefined) {
return;
}
const self = this;
const handle = function() { self.reloadDatatable(); };
const handle = () => { this.reloadDatatable(); };
for (let eventName of events.split(' ')) {
document.addEventListener(eventName, handle);
@@ -52,55 +49,49 @@ export default class KimaiDatatable extends KimaiPlugin {
document.addEventListener('filter-change', handle);
}
reloadDatatable() {
const self = this;
const contentArea = this.contentArea;
const durations = this.getContainer().getPlugin('timesheet-duration');
const toolbarSelector = this.getContainer().getPlugin('toolbar').getSelector();
const form = jQuery(toolbarSelector);
let loading = '<div class="overlay"><i class="fas fa-sync fa-spin"></i></div>';
jQuery(contentArea).append(loading);
// remove the empty fields to prevent errors
let formData = jQuery(toolbarSelector + ' :input')
.filter(function(index, element) {
return jQuery(element).val() !== '';
})
.serialize();
jQuery.ajax({
url: form.attr('action'),
type: form.attr('method'),
data: formData,
success: function(html) {
jQuery(contentArea).replaceWith(
jQuery(html).find(contentArea)
);
durations.updateRecords();
self.fixDropdowns();
},
error: function(xhr, err) {
form.submit();
}
});
/**
* @param {string} selector
* @private
*/
registerContextMenu(selector)
{
KimaiContextMenu.createForDataTable(selector);
}
/**
* show dropdown menu upwards, if it is outside the visible viewport
*/
fixDropdowns() {
const docHeight = jQuery(document).height();
jQuery(this.selector + ' [data-toggle=dropdown]').each(function() {
const parent = jQuery(this).parent();
const menu = parent.find('.dropdown-menu');
reloadDatatable()
{
const toolbarSelector = this.getContainer().getPlugin('toolbar').getSelector();
if (parent && menu) {
if ((parent.offset().top + parent.outerHeight() + menu.outerHeight()) > docHeight) {
parent.addClass('dropup').removeClass('dropdown');
}
}
/** @type {HTMLFormElement} form */
const form = document.querySelector(toolbarSelector);
const callback = (text) => {
const temp = document.createElement('div');
temp.innerHTML = text;
const newContent = temp.querySelector(this._contentArea);
document.querySelector(this._contentArea).replaceWith(newContent);
this.registerContextMenu(this._selector);
document.dispatchEvent(new Event('kimai.reloadedContent'));
};
document.dispatchEvent(new CustomEvent('kimai.reloadContent', {detail: this._contentArea}));
if (form === null) {
this.fetch(document.location)
.then(response => {
response.text().then(callback);
})
.catch(() => {
document.location.reload();
});
return;
}
this.fetchForm(form)
.then(response => {
response.text().then(callback);
})
.catch(() => {
form.submit();
});
}
}

View File

@@ -9,8 +9,6 @@
* [KIMAI] KimaiDatatableColumnView: manages the visibility of data-table columns in cookies
*/
import Cookies from 'js-cookie';
import jQuery from 'jquery';
import KimaiPlugin from "../KimaiPlugin";
export default class KimaiDatatableColumnView extends KimaiPlugin {
@@ -29,80 +27,110 @@ export default class KimaiDatatableColumnView extends KimaiPlugin {
if (dataTable === null) {
return;
}
this.id = dataTable.getAttribute(this.dataAttribute);
this.modal = document.getElementById('modal_' + this.id);
this.bindButtons();
}
bindButtons() {
let self = this;
this.modal.querySelector('button[data-type=save]').addEventListener('click', function() {
self.saveVisibility();
this._id = dataTable.getAttribute(this.dataAttribute);
this._modal = document.getElementById('modal_' + this._id);
this._modal.addEventListener('show.bs.modal', () => {
this._evaluateCheckboxes();
});
this.modal.querySelector('button[data-type=reset]').addEventListener('click', function() {
self.resetVisibility();
this._modal.querySelector('button[data-type=save]').addEventListener('click', () => {
this._saveVisibility();
});
for (let checkbox of this.modal.querySelectorAll('form input[type=checkbox]')) {
checkbox.addEventListener('click', function () {
self.changeVisibility(checkbox.getAttribute('name'), checkbox.checked);
this._modal.querySelector('button[data-type=reset]').addEventListener('click', (event) => {
this._resetVisibility(event.currentTarget);
});
this._modal.querySelectorAll('input[name=datatable_profile]').forEach(element => {
element.addEventListener('change', () => {
const form = this._modal.getElementsByTagName('form')[0];
this.fetchForm(form, {}, element.getAttribute('data-href'))
.then(() => {
// the local storage is read in the login screen to set a cookie,
// which triggers the session switch in ProfileSubscriber
localStorage.setItem('kimai_profile', element.getAttribute('value'));
document.location.reload();
})
.catch(() => {
form.setAttribute('action', element.getAttribute('data-href'));
form.submit();
});
});
});
for (let checkbox of this._modal.querySelectorAll('form input[type=checkbox]')) {
checkbox.addEventListener('change', () => {
this._changeVisibility(checkbox.getAttribute('name'), checkbox.checked);
});
}
}
saveVisibility() {
const form = this.modal.getElementsByTagName('form')[0];
let settings = {};
for (let checkbox of form.querySelectorAll('input[type=checkbox]')) {
settings[checkbox.getAttribute('name')] = checkbox.checked;
_evaluateCheckboxes() {
const form = this._modal.getElementsByTagName('form')[0];
const table = document.getElementsByClassName('datatable_' + this._id)[0];
for (let columnElement of table.getElementsByTagName('th')) {
const fieldName = columnElement.getAttribute('data-field');
if (fieldName === null) {
continue;
}
const checkbox = form.querySelector('input[name=' + fieldName + ']');
if (checkbox === null) {
continue;
}
checkbox.checked = window.getComputedStyle(columnElement).display !== 'none';
}
Cookies.set(form.getAttribute('name'), JSON.stringify(settings), {expires: 365, SameSite: 'Strict'});
jQuery(this.modal).modal('toggle');
}
resetVisibility() {
const form = this.modal.getElementsByTagName('form')[0];
Cookies.remove(form.getAttribute('name'));
for (let checkbox of form.querySelectorAll('input[type=checkbox]')) {
if (!checkbox.checked) {
checkbox.click();
}
}
jQuery(this.modal).modal('toggle');
_saveVisibility() {
const form = this._modal.getElementsByTagName('form')[0];
this.fetchForm(form)
.then(() => {
document.location.reload();
})
.catch(() => {
form.submit();
});
}
changeVisibility(columnName, checked) {
const tables = document.getElementsByClassName('datatable_' + this.id);
for (let tableBox of tables) {
let column = 0;
let foundColumn = false;
let table = tableBox.getElementsByClassName('dataTable')[0];
for (let columnElement of table.getElementsByTagName('th')) {
if (columnElement.getAttribute('data-field') === columnName) {
foundColumn = true;
break;
_resetVisibility(button) {
const form = this._modal.getElementsByTagName('form')[0];
this.fetchForm(form, {}, button.getAttribute('formaction'))
.then(() => {
document.location.reload();
})
.catch(() => {
form.setAttribute('action', button.getAttribute('formaction'));
form.submit();
});
}
_changeVisibility(columnName, checked) {
for (const tableBox of document.getElementsByClassName('datatable_' + this._id)) {
let targetClasses = null;
for (let element of tableBox.getElementsByClassName('col_' + columnName)) {
// only calculate that once and re-use the cached class list
if (targetClasses === null) {
let removeClass = '-none';
let addClass = 'd-table-cell';
if (!checked) {
removeClass = '-table-cell';
addClass = 'd-none';
}
targetClasses = '';
element.classList.forEach(
function (name, index, listObj) { // eslint-disable-line no-unused-vars
if (name.indexOf(removeClass) === -1) {
targetClasses += ' ' + name;
}
}
);
if (targetClasses.indexOf(addClass) === -1) {
targetClasses += ' ' + addClass;
}
}
if (columnElement.getAttribute('colspan') !== null) {
console.log('Tables with colspans are not supported!');
}
column++;
}
if (!foundColumn) {
return;
}
for (let rowElement of table.getElementsByTagName('tr')) {
if (rowElement.children[column] === undefined) {
continue;
}
if (checked) {
rowElement.children[column].classList.remove('hidden');
} else {
rowElement.children[column].classList.add('hidden');
}
element.className = targetClasses;
}
}
}

View File

@@ -1,72 +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] KimaiDatePicker: single date selects (currently unused)
*/
import jQuery from 'jquery';
import KimaiPlugin from '../KimaiPlugin';
export default class KimaiDatePicker extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
getId() {
return 'date-picker';
}
activateDatePicker(selector) {
const TRANSLATE = this.getContainer().getTranslation();
const DATE_UTILS = this.getContainer().getPlugin('date');
const firstDow = this.getConfiguration('first_dow_iso') % 7;
jQuery(selector + ' ' + this.selector).each(function(index) {
let localeFormat = jQuery(this).data('format');
jQuery(this).daterangepicker({
singleDatePicker: true,
showDropdowns: true,
autoUpdateInput: false,
drops: 'down',
locale: {
format: localeFormat,
firstDay: firstDow,
applyLabel: TRANSLATE.get('confirm'),
cancelLabel: TRANSLATE.get('cancel'),
customRangeLabel: TRANSLATE.get('customRange'),
daysOfWeek: DATE_UTILS.getWeekDaysShort(),
monthNames: DATE_UTILS.getMonthNames(),
}
});
jQuery(this).on('show.daterangepicker', function (ev, picker) {
if (picker.element.offset().top - jQuery(window).scrollTop() + picker.container.outerHeight() + 30 > jQuery(window).height()) {
// "up" is not possible here, because the code is triggered on many mobile phones and the picker then appears out of window
picker.drops = 'auto';
picker.move();
}
});
jQuery(this).on('apply.daterangepicker', function(ev, picker) {
jQuery(this).val(picker.startDate.format(localeFormat));
jQuery(this).trigger("change");
});
});
}
destroyDatePicker(selector) {
jQuery(selector + ' ' + this.selector).each(function(index) {
if (jQuery(this).data('daterangepicker') !== undefined) {
jQuery(this).data('daterangepicker').remove();
}
});
}
}

View File

@@ -1,95 +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] KimaiDateRangePicker: activate the (daterange picker) compound field in toolbar
*/
import jQuery from 'jquery';
import KimaiPlugin from '../KimaiPlugin';
import moment from 'moment';
export default class KimaiDateRangePicker extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
getId() {
return 'date-range-picker';
}
activateDateRangePicker(selector) {
const TRANSLATE = this.getContainer().getTranslation();
const DATE_UTILS = this.getContainer().getPlugin('date');
const firstDow = this.getConfiguration('first_dow_iso') % 7;
jQuery(selector + ' ' + this.selector).each(function(index) {
let localeFormat = jQuery(this).data('format');
let separator = jQuery(this).data('separator');
let rangesList = {};
rangesList[TRANSLATE.get('today')] = [moment(), moment()];
rangesList[TRANSLATE.get('yesterday')] = [moment().subtract(1, 'days'), moment().subtract(1, 'days')];
rangesList[TRANSLATE.get('thisWeek')] = [moment().startOf('isoWeek'), moment().endOf('isoWeek')];
rangesList[TRANSLATE.get('lastWeek')] = [moment().subtract(1, 'week').startOf('isoWeek'), moment().subtract(1, 'week').endOf('isoWeek')];
if (firstDow === 0) { // sunday = 0
rangesList[TRANSLATE.get('thisWeek')] = [moment().startOf('isoWeek').subtract(1, 'day'), moment().endOf('isoWeek').subtract(1, 'day')];
rangesList[TRANSLATE.get('lastWeek')] = [moment().subtract(1, 'week').startOf('isoWeek').subtract(1, 'day'), moment().subtract(1, 'week').endOf('isoWeek').subtract(1, 'day')];
}
rangesList[TRANSLATE.get('thisMonth')] = [moment().startOf('month'), moment().endOf('month')];
rangesList[TRANSLATE.get('lastMonth')] = [moment().subtract(1, 'month').startOf('month'), moment().subtract(1, 'month').endOf('month')];
rangesList[TRANSLATE.get('thisYear')] = [moment().startOf('year'), moment().endOf('year')];
rangesList[TRANSLATE.get('lastYear')] = [moment().subtract(1, 'year').startOf('year'), moment().subtract(1, 'year').endOf('year')];
jQuery(this).daterangepicker({
showDropdowns: true,
autoUpdateInput: false,
autoApply: false,
linkedCalendars: true,
drops: 'down',
locale: {
separator: separator,
format: localeFormat,
firstDay: firstDow,
applyLabel: TRANSLATE.get('confirm'),
cancelLabel: TRANSLATE.get('cancel'),
customRangeLabel: TRANSLATE.get('customRange'),
daysOfWeek: DATE_UTILS.getWeekDaysShort(),
monthNames: DATE_UTILS.getMonthNames(),
},
ranges: rangesList,
alwaysShowCalendars: true
});
jQuery(this).on('show.daterangepicker', function (ev, picker) {
if (picker.element.offset().top - jQuery(window).scrollTop() + picker.container.outerHeight() + 30 > jQuery(window).height()) {
// "up" is not possible here, because the code is triggered on many mobile phones and the picker then appears out of window
picker.drops = 'auto';
picker.move();
}
});
jQuery(this).on('apply.daterangepicker', function(ev, picker) {
jQuery(this).val(picker.startDate.format(localeFormat) + ' - ' + picker.endDate.format(localeFormat));
jQuery(this).data('begin', picker.startDate.format(localeFormat));
jQuery(this).data('end', picker.endDate.format(localeFormat));
jQuery(this).trigger("change");
});
});
}
destroyDateRangePicker(selector) {
jQuery(selector + ' ' + this.selector).each(function(index) {
if (jQuery(this).data('daterangepicker') !== undefined) {
jQuery(this).data('daterangepicker').remove();
}
});
}
}

View File

@@ -1,75 +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] KimaiDateTimePicker: activate the (datetime picker) field in timesheet edit dialog
*/
import jQuery from 'jquery';
import KimaiPlugin from '../KimaiPlugin';
export default class KimaiDateTimePicker extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
getId() {
return 'date-time-picker';
}
activateDateTimePicker(selector) {
const TRANSLATE = this.getContainer().getTranslation();
const DATE_UTILS = this.getContainer().getPlugin('date');
const firstDow = this.getConfiguration('first_dow_iso') % 7;
const is24hours = this.getConfiguration('twentyFourHours');
jQuery(selector + ' ' + this.selector).each(function(index) {
let localeFormat = jQuery(this).data('format');
jQuery(this).daterangepicker({
singleDatePicker: true,
timePicker: true,
timePicker24Hour: is24hours,
showDropdowns: true,
autoUpdateInput: false,
drops: 'down',
locale: {
format: localeFormat,
firstDay: firstDow,
applyLabel: TRANSLATE.get('confirm'),
cancelLabel: TRANSLATE.get('cancel'),
customRangeLabel: TRANSLATE.get('customRange'),
daysOfWeek: DATE_UTILS.getWeekDaysShort(),
monthNames: DATE_UTILS.getMonthNames(),
}
});
jQuery(this).on('show.daterangepicker', function (ev, picker) {
if (picker.element.offset().top - jQuery(window).scrollTop() + picker.container.outerHeight() + 30 > jQuery(window).height()) {
// "up" is not possible here, because the code is triggered on many mobile phones and the picker then appears out of window
picker.drops = 'auto';
picker.move();
}
});
jQuery(this).on('apply.daterangepicker', function(ev, picker) {
jQuery(this).val(picker.startDate.format(localeFormat));
jQuery(this).trigger("change");
});
});
}
destroyDateTimePicker(selector) {
jQuery(selector + ' ' + this.selector).each(function(index) {
if (jQuery(this).data('daterangepicker') !== undefined) {
jQuery(this).data('daterangepicker').remove();
}
});
}
}

View File

@@ -10,71 +10,241 @@
*/
import KimaiPlugin from '../KimaiPlugin';
import moment from 'moment';
import { DateTime, Duration } from 'luxon';
export default class KimaiDateUtils extends KimaiPlugin {
getId() {
getId()
{
return 'date';
}
init()
{
if (this.getConfigurations().is24Hours()) {
this.timeFormat = 'HH:mm';
} else {
this.timeFormat = 'hh:mm a';
}
this.durationFormat = this.getConfiguration('formatDuration');
this.dateFormat = this.getConfiguration('formatDate');
}
/**
* @param {string} dateTime
* @see https://moment.github.io/luxon/#/formatting?id=table-of-tokens
* @param {string} format
* @returns {string}
* @private
*/
_parseFormat(format)
{
format = format.replace('DD', 'dd');
format = format.replace('D', 'd');
format = format.replace('MM', 'LL');
format = format.replace('M', 'L');
format = format.replace('YYYY', 'yyyy');
format = format.replace('YY', 'yy');
format = format.replace('A', 'a');
return format;
}
/**
* @param {string} format
* @param {string|Date|null|undefined} dateTime
* @returns {string}
*/
getFormattedDate(dateTime) {
return moment(dateTime).format(this.getConfiguration('formatDate'));
}
format(format, dateTime)
{
let newDate = null;
getWeekDaysShort() {
return moment.localeData().weekdaysShort();
}
if (dateTime === null || dateTime === undefined) {
newDate = DateTime.now();
} else if (dateTime instanceof Date) {
newDate = DateTime.fromJSDate(dateTime);
} else {
newDate = DateTime.fromISO(dateTime);
}
getMonthNames() {
return moment.localeData().months();
}
formatDuration(since) {
const duration = moment.duration(moment(new Date()).diff(moment(since)));
return this.formatMomentDuration(duration);
}
formatSeconds(seconds) {
const duration = moment.duration('PT' + seconds + 'S');
return this.formatMomentDuration(duration);
// using locale english here prevents that that AM/PM is translated to the
// locale variant: e.g. "ko" translates it to 오후 / 오전
return newDate.toFormat(this._parseFormat(format), { locale: 'en-us' });
}
/**
* @param {moment.Duration} duration
* @returns {string|*}
* @param {string|Date} dateTime
* @returns {string}
*/
formatMomentDuration(duration) {
const hours = parseInt(duration.asHours());
const minutes = duration.minutes();
return this.formatTime(hours, minutes);
getFormattedDate(dateTime)
{
return this.format(this._parseFormat(this.dateFormat), dateTime);
}
formatTime(hours, minutes) {
let format = this.getConfiguration('formatDuration');
/**
* Returns a "YYYY-MM-DDTHH:mm:ss" formatted string in local time.
* This can take Date objects (e.g. from FullCalendar) and turn them into the correct format.
*
* @param {Date|DateTime} date
* @param {boolean|undefined} isUtc
* @return {string}
*/
formatForAPI(date, isUtc = false)
{
if (date instanceof Date) {
date = DateTime.fromJSDate(date);
}
if (isUtc === undefined || !isUtc) {
date = date.toUTC();
}
return date.toISO({ includeOffset: false, suppressMilliseconds: true });
}
/**
* @param {string} date
* @param {string} format
* @return {DateTime}
*/
fromFormat(date, format)
{
// using locale en-us here prevents that Luxon expects the localized
// version of AM/PM (e.g. 오후 / 오전 for locale "ko")
return DateTime.fromFormat(date, this._parseFormat(format), { locale: 'en-us' });
}
/**
* @param {string|null} date
* @param {string|null} time
* @return {DateTime}
*/
fromHtml5Input(date, time)
{
date = date ?? '';
time = time ?? '';
if (date === '' && time === '') {
return DateTime.invalid('Empty date and time given');
}
if (date !== '' && time !== '') {
date = date + 'T' + time;
}
return DateTime.fromISO(date);
}
/**
* @param {string} date
* @param {string} format
* @return {boolean}
*/
isValidDateTime(date, format)
{
return this.fromFormat(date, format).isValid;
}
/**
* Adds a string like "00:30:00" or "01:15" to a given date.
*
* @param {Date} date
* @param {string} duration
* @return {Date}
*/
addHumanDuration(date, duration)
{
/** @type {DateTime} newDate */
let newDate = null;
if (date instanceof Date) {
newDate = DateTime.fromJSDate(date);
} else if (date instanceof DateTime) {
newDate = date;
} else {
throw 'addHumanDuration() needs a JS Date';
}
const parsed = DateTime.fromISO(duration);
const today = DateTime.now().startOf('day');
const timeOfDay = parsed.diff(today);
return newDate.plus(timeOfDay).toJSDate();
}
/**
* @param {string|integer|null} since
* @return {string}
*/
formatDuration(since)
{
let duration = null;
if (typeof since === 'string') {
duration = DateTime.now().diff(DateTime.fromISO(since));
} else {
duration = Duration.fromISO('PT' + (since === null ? 0 : since) + 'S');
}
return this.formatLuxonDuration(duration);
}
/**
* @param {integer} seconds
* @return {string}
*/
formatSeconds(seconds)
{
return this.formatLuxonDuration(Duration.fromObject({seconds: seconds}));
}
/**
* @param {Duration} duration
* @returns {string}
* @private
*/
formatLuxonDuration(duration)
{
duration = duration.shiftTo('hours', 'minutes', 'seconds');
return this.formatAsDuration(duration.hours, duration.minutes);
}
/**
* @param {Date} date
* @param {boolean|undefined} isUtc
* @return {string}
*/
formatTime(date, isUtc = false)
{
let newDate = DateTime.fromJSDate(date);
if (isUtc === undefined || !isUtc) {
newDate = newDate.toUTC();
}
// .utc() is required for calendar
return newDate.toFormat(this.timeFormat);
}
/**
* TODO remove seconds
*
* @param {int} hours
* @param {int} minutes
* @return {string}
*/
formatAsDuration(hours, minutes)
{
let format = this.durationFormat;
if (hours < 0 || minutes < 0) {
hours = Math.abs(hours);
minutes = Math.abs(minutes);
if (minutes > 0 || hours > 0) {
format = '-' + format;
}
format = '-' + format;
}
// special case for hours, as they can overflow the 24h barrier - Kimai does not support days as duration unit
if (hours < 10) {
hours = '0' + hours;
}
return format.replace('%h', hours).replace('%m', ('0' + minutes).substr(-2));
return format.replace('%h', (hours < 10 ? '0' + hours : hours)).replace('%m', ('0' + minutes).slice(-2));
//return format.replace('%h', (hours < 10 ? '0' + hours : hours)).replace('%m', ('0' + minutes).slice(-2)).replace('%s', ('0' + seconds).slice(-2));
}
/**
@@ -83,32 +253,52 @@ export default class KimaiDateUtils extends KimaiPlugin {
*/
getSecondsFromDurationString(duration)
{
duration = duration.trim().toUpperCase();
let momentDuration = moment.duration(NaN);
const luxonDuration = this.parseDuration(duration);
if (duration.indexOf(':') !== -1) {
momentDuration = moment.duration(duration);
} else if (duration.indexOf('.') !== -1 || duration.indexOf(',') !== -1) {
duration = duration.replace(/,/, '.');
duration = (parseFloat(duration) * 3600).toString();
momentDuration = moment.duration('PT' + duration + 'S');
} else if (duration.indexOf('H') !== -1 || duration.indexOf('M') !== -1 || duration.indexOf('S') !== -1) {
/* D for days does not work, because 'PT1H' but with days 'P1D' is used */
momentDuration = moment.duration('PT' + duration);
} else {
let c = parseInt(duration);
let d = parseInt(duration).toFixed();
if (!isNaN(c) && duration === d) {
duration = (c * 3600).toString();
momentDuration = moment.duration('PT' + duration + 'S');
}
}
if (!momentDuration.isValid()) {
if (luxonDuration === null || !luxonDuration.isValid) {
return 0;
}
return momentDuration.asSeconds();
return luxonDuration.as('seconds');
}
/**
* @param {string} duration
* @returns {Duration}
*/
parseDuration(duration)
{
if (duration === undefined || duration === null || duration === '') {
return new Duration({seconds: 0});
}
duration = duration.trim().toUpperCase();
let luxonDuration = null;
if (duration.indexOf(':') !== -1) {
const [, hours, minutes, seconds] = duration.match(/(\d+):(\d+)(?::(\d+))*/);
luxonDuration = Duration.fromObject({hours: hours, minutes: minutes, seconds: seconds});
} else if (duration.indexOf('.') !== -1 || duration.indexOf(',') !== -1) {
duration = duration.replace(/,/, '.');
duration = (parseFloat(duration) * 3600).toString();
luxonDuration = Duration.fromISO('PT' + duration + 'S');
} else if (duration.indexOf('H') !== -1 || duration.indexOf('M') !== -1 || duration.indexOf('S') !== -1) {
/* D for days does not work, because 'PT1H' but with days 'P1D' is used */
luxonDuration = Duration.fromISO('PT' + duration);
} else {
let c = parseInt(duration);
const d = parseInt(duration).toFixed();
if (!isNaN(c) && duration === d) {
duration = (c * 3600).toString();
luxonDuration = Duration.fromISO('PT' + duration + 'S');
}
}
if (luxonDuration === null || !luxonDuration.isValid) {
return new Duration({seconds: 0});
}
return luxonDuration;
}
}

View File

@@ -22,6 +22,10 @@ export default class KimaiEscape extends KimaiPlugin {
* @returns {string}
*/
escapeForHtml(title) {
if (title === undefined || title === null) {
return '';
}
const tagsToReplace = {
'&': '&amp;',
'<': '&lt;',
@@ -31,5 +35,5 @@ export default class KimaiEscape extends KimaiPlugin {
return title.replace(/[&<>]/g, function(tag) {
return tagsToReplace[tag] || tag;
});
};
}
}

View File

@@ -13,18 +13,24 @@ import KimaiPlugin from "../KimaiPlugin";
export default class KimaiEvent extends KimaiPlugin {
getId() {
getId()
{
return 'event';
}
trigger(name, details) {
if (name === null || name === undefined) {
/**
* @param {string} name
* @param {string|array|object|null} details
*/
trigger(name, details = null)
{
if (name === '') {
return;
}
for(let event of name.split(' ')) {
for (const event of name.split(' ')) {
let triggerEvent = new Event(event);
if (details !== undefined) {
if (details !== null) {
triggerEvent = new CustomEvent(event, {detail: details});
}
document.dispatchEvent(triggerEvent);

View File

@@ -0,0 +1,80 @@
/*
* 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] KimaiEscape: sanitize strings
*/
import KimaiPlugin from "../KimaiPlugin";
export default class KimaiFetch extends KimaiPlugin {
getId() {
return 'fetch';
}
/**
* @param {string} url
* @param {object} options
* @returns {Promise<Response>}
*/
fetch(url, options = {}) {
if (options.headers === undefined) {
options.headers = new Headers();
}
options.headers.append('X-Requested-With', 'Kimai');
options = {...{
redirect: 'follow',
}, ...options};
return new Promise((resolve, reject) => {
fetch(url, options).then(response => {
if (response.ok) {
if (response.status === 201 && response.headers.has('x-modal-redirect')) {
window.location = response.headers.get('x-modal-redirect');
return;
}
// "ok" is only in status code range of 2xx
resolve(response);
return;
}
let stopPropagation = false;
switch (response.status) {
case 403: {
if (response.headers.has('login-required')) {
const loginUrl = this.getConfiguration('login').toString();
/** @type {KimaiAlert} alert */
const alert = this.getContainer().getPlugin('alert');
alert.question(this.translate('login.required'), (result) => {
if (result === true) {
window.location.replace(loginUrl);
}
});
stopPropagation = true;
}
break;
}
default: {
console.log('Some error occurred');
break;
}
}
if (!stopPropagation) {
reject(response);
}
})
.catch(error => {
console.log('Error occurred while talking to Kimai backend', error);
reject(error);
});
});
}
}

View File

@@ -10,35 +10,44 @@
*/
import KimaiPlugin from "../KimaiPlugin";
import KimaiFormPlugin from "../forms/KimaiFormPlugin";
export default class KimaiForm extends KimaiPlugin {
getId() {
getId()
{
return 'form';
}
activateForm(formSelector, container) {
this.getContainer().getPlugin('date-range-picker').activateDateRangePicker(formSelector);
this.getContainer().getPlugin('date-time-picker').activateDateTimePicker(formSelector);
this.getContainer().getPlugin('date-picker').activateDatePicker(formSelector);
this.getContainer().getPlugin('autocomplete').activateAutocomplete(formSelector);
this.getContainer().getPlugin('form-select').activateSelectPicker(formSelector, container);
activateForm(formSelector)
{
[].slice.call(document.querySelectorAll(formSelector)).map((form) => {
for (const plugin of this.getContainer().getPlugins()) {
if (plugin instanceof KimaiFormPlugin && plugin.supportsForm(form)) {
plugin.activateForm(form);
}
}
});
}
destroyForm(formSelector) {
this.getContainer().getPlugin('form-select').destroySelectPicker(formSelector);
this.getContainer().getPlugin('autocomplete').destroyAutocomplete(formSelector);
this.getContainer().getPlugin('date-picker').destroyDatePicker(formSelector);
this.getContainer().getPlugin('date-time-picker').destroyDateTimePicker(formSelector);
this.getContainer().getPlugin('date-range-picker').destroyDateRangePicker(formSelector);
destroyForm(formSelector)
{
[].slice.call(document.querySelectorAll(formSelector)).map((form) => {
for (const plugin of this.getContainer().getPlugins()) {
if (plugin instanceof KimaiFormPlugin && plugin.supportsForm(form)) {
plugin.destroyForm(form);
}
}
});
}
/**
* @param {HTMLFormElement} form
* @param {Object} overwrites
* @param {boolean} removeEmpty
* @returns {string}
*/
convertFormDataToQueryString(form, overwrites = {})
convertFormDataToQueryString(form, overwrites = {}, removeEmpty = false)
{
let serialized = [];
let data = new FormData(form);
@@ -48,7 +57,9 @@ export default class KimaiForm extends KimaiPlugin {
}
for (let row of data) {
serialized.push(encodeURIComponent(row[0]) + "=" + encodeURIComponent(row[1]));
if (!removeEmpty || row[1] !== '') {
serialized.push(encodeURIComponent(row[0]) + "=" + encodeURIComponent(row[1]));
}
}
return serialized.join('&');

View File

@@ -1,298 +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] KimaiFormSelect: enhanced functionality for HTML select's
*/
import KimaiPlugin from "../KimaiPlugin";
import jQuery from "jquery";
export default class KimaiFormSelect extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
getId() {
return 'form-select';
}
init() {
// selects the original value inside select2 dropdowns, as the "reset" event (the updated option)
// is not automatically catched by select2
jQuery('body').on('reset', 'form', function(event) {
setTimeout(function() {
jQuery(event.target).find(this.selector).trigger('change');
}, 10);
});
const self = this;
// Function to match the name of the parent and not only the names of the children
// Based on the original matcher function of Select2: https://github.com/select2/select2/blob/5765090318c4d382ae56463cfa25ba8ca7bdd495/src/js/select2/defaults.js#L272
// More information: https://select2.org/searching | https://github.com/select2/docs/blob/develop/pages/11.searching/docs.md
this.matcher = function (params, data) {
// Always return the object if there is nothing to compare
if (jQuery.trim(params.term) === '') {
return data;
}
// Check whether options has children
let hasChildren = data.children && data.children.length > 0;
// Split search param by space to search for all terms and convert all to uppercase
let terms = params.term.toUpperCase().split(' ');
let original = data.text.toUpperCase();
// Always return the parent option including its children, when the name matches one of the params
// Check if the text contains all or at least one of the terms
let foundAll = true;
let foundOne = false;
let missingTerms = [];
terms.forEach(function(item, index) {
if (original.indexOf(item) > -1) {
foundOne = true;
} else {
foundAll = false;
missingTerms.push(item);
}
});
// If the option element contains all terms, return it
if (foundAll) {
return data;
}
// Do a recursive check for options with children
if (hasChildren) {
// If the parent already contains one or more search terms, proceed only with the missing ones
// First: Clone the original params object...
let newParams = jQuery.extend(true, {}, params);
if (foundOne) {
newParams.term = missingTerms.join(' ');
} else {
newParams.term = params.term;
}
// Clone the data object if there are children
// This is required as we modify the object to remove any non-matches
let match = jQuery.extend(true, {}, data);
// Check each child of the option
for (let c = data.children.length - 1; c >= 0; c--) {
let child = data.children[c];
let matches = self.matcher(newParams, child);
// If there wasn't a match, remove the object in the array
if (matches === null) {
match.children.splice(c, 1);
}
}
// If any children matched, return the new object
if (match.children.length > 0) {
return match;
}
}
// If the option or its children do not contain the term, don't return anything
return null;
}
}
activateSelectPickerByElement(node, container) {
let options = {};
if (container !== undefined) {
options = {
dropdownParent: jQuery(container),
};
}
options = {...options, ...{
language: this.getConfiguration('locale').replace('_', '-'),
theme: "bootstrap",
matcher: this.matcher,
dropdownAutoWidth: true,
width: "resolve"
}};
const element = jQuery(node);
if (node.dataset['renderer'] !== undefined && node.dataset['renderer'] === 'color') {
const templateResultFunc = function (state) {
return jQuery('<span><span style="background-color:'+state.id+'; width: 20px; height: 20px; display: inline-block; margin-right: 10px;">&nbsp;</span>' + state.text + '</span>');
};
const colorOptions = {...options, ...{
templateSelection: templateResultFunc,
templateResult: templateResultFunc
}};
element.select2(colorOptions);
} else {
element.select2(options);
}
// this is a bugfix for safari, which does render the dropdown only with correct width upon the second opening
// see https://github.com/select2/select2/issues/4678
element.on('select2:open', function (ev) {
if (element.data('performing-reopen') === undefined || element.data('performing-reopen') === null) {
element.data('performing-reopen', true);
element.select2('close');
element.select2('open');
}
});
}
activateSelectPicker(selector, container) {
const self = this;
jQuery(selector + ' ' + this.selector).each(function(i, el) {
self.activateSelectPickerByElement(el, container);
});
}
destroySelectPicker(selector) {
jQuery(selector + ' ' + this.selector).select2('destroy');
}
updateOptions(selectIdentifier, data) {
let select = jQuery(selectIdentifier);
let emptyOption = jQuery(selectIdentifier + ' option[value=""]');
const selectedValue = select.val();
select.find('option').remove().end().find('optgroup').remove().end();
if (emptyOption.length !== 0) {
select.append(this._createOption(emptyOption.text(), ''));
}
let emptyOpts = [];
let options = [];
let titlePattern = null;
if (select[0] !== undefined && select[0].dataset !== undefined && select[0].dataset['optionPattern'] !== undefined) {
titlePattern = select[0].dataset['optionPattern'];
}
if (titlePattern === null || titlePattern === '') {
titlePattern = '{name}';
}
for (const [key, value] of Object.entries(data)) {
if (key === '__empty__') {
for (const entity of value) {
emptyOpts.push(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
}
continue;
}
let optGroup = this._createOptgroup(key);
for (const entity of value) {
optGroup.appendChild(this._createOption(this._getTitleFromPattern(titlePattern, entity), entity.id));
}
options.push(optGroup);
}
select.append(options);
select.append(emptyOpts);
// if available, re-select the previous selected option (mostly usable for global activities)
select.val(selectedValue);
// pre-select an option if it is the only available one
if (select.val() === '' || select.val() === null) {
const allOptions = select.find('option');
const optionLength = allOptions.length;
let selectOption = '';
if (optionLength === 1) {
selectOption = allOptions[0].value;
} else if (optionLength === 2 && emptyOption.length === 1) {
selectOption = allOptions[1].value;
}
if (selectOption !== '') {
select.val(selectOption);
}
}
// if we don't trigger the change, the other selects won't reset
select.trigger('change');
// if select2 is active, this will tell the select to refresh
if (select.hasClass('selectpicker')) {
select.trigger('change.select2');
}
}
/**
* @param {string} pattern
* @param {array} entity
* @private
*/
_getTitleFromPattern(pattern, entity) {
const DATE_UTILS = this.getPlugin('date');
const regexp = new RegExp('{[^}]*?}','g');
let title = pattern;
let match = null;
while ((match = regexp.exec(pattern)) !== null) {
const field = match[0].substr(1, match[0].length - 2);
let value = entity[field] === undefined ? null : entity[field];
if ((field === 'start' || field === 'end')) {
if (value === null) {
value = '?';
} else {
value = DATE_UTILS.getFormattedDate(value);
}
}
title = title.replace(new RegExp('{' + field + '}', 'g'), value ?? '');
}
title = title.replace(/- \?-\?/, '');
title = title.replace(/\r\n|\r|\n/g, ' ');
title = title.substr(0, 110);
const chars = '- ';
let start = 0, end = title.length;
while (start < end && chars.indexOf(title[start]) >= 0) {
++start;
}
while (end > start && chars.indexOf(title[end - 1]) >= 0) {
--end;
}
return (start > 0 || end < title.length) ? title.substring(start, end) : title;
}
/**
* @param {string} label
* @param {string} value
* @returns {HTMLElement}
* @private
*/
_createOption(label, value) {
let option = document.createElement('option');
option.innerText = label;
option.value = value;
return option;
}
/**
* @param {string} label
* @returns {HTMLElement}
* @private
*/
_createOptgroup(label) {
let optGroup = document.createElement('optgroup');
optGroup.label = label;
return optGroup;
}
}

View File

@@ -0,0 +1,51 @@
/*
* 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.
*/
import KimaiPlugin from "../KimaiPlugin";
export default class KimaiHotkeys extends KimaiPlugin {
getId()
{
return 'hotkeys';
}
init()
{
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key
const selector = '[data-hotkey="ctrl+Enter"]';
window.addEventListener('keyup', (ev) => {
if (ev.ctrlKey && ev.key === 'Enter') {
const elements = [...document.querySelectorAll(selector)].filter(element => this.isVisible(element));
if (elements.length > 1) {
console.warn('KimaiHotkeys: More than one visible element matches ${selector}. No action triggered.');
}
if (elements.length === 1) {
ev.stopPropagation();
ev.preventDefault();
elements[0].click();
}
}
})
}
// adopted from Bootstrap 5.1.1, MIT
isVisible (element)
{
if (!element || element.getClientRects().length === 0) {
return false;
}
return getComputedStyle(element).getPropertyValue('visibility') === 'visible';
}
}

View File

@@ -10,63 +10,79 @@
*/
import KimaiPlugin from '../KimaiPlugin';
import jQuery from "jquery";
export default class KimaiMultiUpdateTable extends KimaiPlugin {
init() {
const self = this;
jQuery('body').
on('change', '#multi_update_all', function(event) {
jQuery('.multi_update_single').prop('checked', jQuery(event.target).prop('checked'));
self.toggleForm();
})
.on('change', '.multi_update_single', function(event) {
self.toggleForm();
})
.on('change', '#multi_update_table_action', function(event) {
const selectedItem = jQuery('#multi_update_table_action option:selected');
const selectedVal = selectedItem.val();
init()
{
if (document.getElementById('multi_update_all') === null) {
return;
}
if (selectedVal === '') {
return;
// we have to attach it to the "page-body" div, because section.content can be replaced
// via KimaiDatable and everything inside will be removed, including event listeners
const element = document.querySelector('div.page-body');
element.addEventListener('change', (event) => {
if (event.target.matches('#multi_update_all')) {
// the "check all" checkbox in the upper start corner of the table
const checked = event.target.checked;
for (const element of document.querySelectorAll('.multi_update_single')) {
element.checked = checked;
}
const form = jQuery('#multi_update_form form');
const selectedText = selectedItem.text();
const ids = self.getSelectedIds();
const question = form.attr('data-question').replace(/%action%/, selectedText).replace(/%count%/, ids.length);
self.getContainer().getPlugin('alert').question(question, function(value) {
this._toggleForm();
event.stopPropagation();
} else if (event.target.matches('.multi_update_single')) {
// single checkboxes in front of each row
this._toggleForm();
event.stopPropagation();
}
});
element.addEventListener('click', (event) => {
if (event.target.matches('.multi_update_table_action')) {
const selectedItem = event.target;
const ids = this._getSelectedIds();
const form = document.getElementById('multi_update_form');
const question = form.dataset['question'].replace(/%action%/, selectedItem.textContent).replace(/%count%/, ids.length.toString());
/** @type {KimaiAlert} ALERT */
const ALERT = this.getPlugin('alert');
ALERT.question(question, function(value) {
if (value) {
form.attr('action', selectedVal).submit();
} else {
jQuery('#multi_update_table_action').val('').trigger('change');
const form = document.getElementById('multi_update_form');
form.action = selectedItem.dataset['href'];
form.submit();
}
});
});
}
});
}
getSelectedIds()
_getSelectedIds()
{
let ids = [];
jQuery('.multi_update_single:checked').each(function(i){
ids[i] = $(this).val();
});
for (const box of document.querySelectorAll('input.multi_update_single:checked')) {
ids.push(box.value);
}
return ids;
}
toggleForm()
_toggleForm()
{
const ids = this.getSelectedIds();
jQuery('#multi_update_table_entities').val(ids.join(','));
const ids = this._getSelectedIds();
document.getElementById('multi_update_table_entities').value = ids.join(',');
if (ids.length > 0) {
jQuery('#multi_update_form').show();
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
element.style.setProperty('display', 'none', 'important');
}
document.getElementById('multi_update_form').style.display = null;//'block';
} else {
jQuery('#multi_update_form').hide();
document.getElementById('multi_update_form').style.setProperty('display', 'none', 'important');
for (const element of document.getElementsByClassName('multi_update_form_hide')) {
element.style.display = null;
}
}
}

View File

@@ -0,0 +1,101 @@
/*
* 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] Notification: notifications for Kimai
*/
import KimaiPlugin from '../KimaiPlugin';
export default class KimaiNotification extends KimaiPlugin {
getId()
{
return 'notification';
}
isSupported()
{
if (!window.Notification) {
return false;
}
if (Notification.permission === 'denied') {
return false;
}
return Notification.permission === "granted";
}
request(callback)
{
try {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
callback(true);
} else if (permission === "default") {
callback(null);
} else {
callback(false);
}
});
} catch (e) {
Notification.requestPermission((permission) => {
if (permission === "granted") {
callback(true);
} else if (permission === "default") {
callback(null);
} else {
callback(false);
}
});
}
}
notify(title, message, icon, options)
{
this.request((permission) => {
if (permission !== true) {
/** @type KimaiAlert */
const ALERT = this.getPlugin('alert');
ALERT.info(message);
}
let opts = {
body: message,
dir: this.getConfigurations().isRTL() ? 'rtl' : 'ltr',
};
//opts.requireInteraction = true;
//opts.renotify = true;
/*
if (options.tag === undefined) {
opts.tag = 'kimai';
}
*/
if (icon !== undefined && icon !== null) {
opts.icon = icon;
}
let nTitle = 'Kimai';
if (title !== null) {
nTitle = nTitle + ': ' + title;
}
if (options !== undefined && options !== null) {
opts = { ...opts, ...options};
}
const notification = new window.Notification(nTitle, opts);
notification.onclick = function () {
window.focus();
notification.close();
};
});
}
}

View File

@@ -1,41 +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] KimaiPauseRecord
*
* allows to pause records
* THIS IS JUST A DRAFT FOR THE DOM, IT IS NOT SUPPORTED IN KIMAI ITSELF!
*/
import jQuery from 'jquery';
import KimaiPlugin from '../KimaiPlugin';
export default class KimaiPauseRecord extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
init() {
this.activate(this.selector);
}
activate(selector) {
jQuery(selector + ' .pull-left i').hover(function () {
let link = jQuery(this).parents('a');
link.attr('href', link.attr('href').replace('/stop', '/pause'));
jQuery(this).removeClass('fa-stop-circle').addClass('fa-pause-circle').addClass('text-orange');
},function () {
let link = jQuery(this).parents('a');
link.attr('href', link.attr('href').replace('/pause', '/stop'));
jQuery(this).removeClass('fa-pause-circle').removeClass('text-orange').addClass('fa-stop-circle');
});
}
}

View File

@@ -13,32 +13,23 @@ import KimaiPlugin from '../KimaiPlugin';
export default class KimaiRecentActivities extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
getId() {
getId()
{
return 'recent-activities';
}
init() {
const menu = document.querySelector(this.selector);
init()
{
this.menu = document.querySelector('header .notifications-menu');
// the menu can be hidden if user has no permissions to see it
if (menu === null) {
// or no timesheet was recorded yet
if (this.menu === null || this.menu.dataset['reload'] === undefined) {
return;
}
const dropdown = menu.querySelector('ul.dropdown-menu');
this.attributes = dropdown.dataset;
this.itemList = dropdown.querySelector('li > ul.menu');
const self = this;
const handle = function() { self.reloadRecentActivities(); };
// don't block initial browser rendering
setTimeout(handle, 500);
const handle = () => {
this._reloadMenu(this.menu.dataset['reload']);
};
document.addEventListener('kimai.recentActivities', handle);
document.addEventListener('kimai.timesheetUpdate', handle);
@@ -49,45 +40,44 @@ export default class KimaiRecentActivities extends KimaiPlugin {
document.addEventListener('kimai.projectDelete', handle);
document.addEventListener('kimai.customerUpdate', handle);
document.addEventListener('kimai.customerDelete', handle);
this._attachAddRemoveFavorite();
}
emptyList() {
this.itemList.innerHTML = '';
}
_attachAddRemoveFavorite()
{
[].slice.call(this.menu.querySelectorAll('a.list-group-item-actions')).map((element) => {
element.addEventListener('click', (event) => {
this._reloadMenu(event.currentTarget.href);
setEntries(entries) {
if (entries.length === 0) {
this.emptyList();
return;
}
event.preventDefault();
event.stopPropagation();
let htmlToInsert = '';
for (let timesheet of entries) {
let label = this.attributes['template']
.replace('%customer%', this.escape(timesheet.project.customer.name))
.replace('%project%', this.escape(timesheet.project.name))
.replace('%activity%', this.escape(timesheet.activity.name))
;
htmlToInsert +=
`<li>` +
`<a href="${ this.attributes['href'].replace('000', timesheet.id) }" data-event="kimai.timesheetStart kimai.timesheetUpdate" class="api-link" data-method="PATCH" data-msg-error="timesheet.start.error" data-msg-success="timesheet.start.success">` +
`<i class="${ this.attributes['icon'] }"></i> ${ label }` +
`</a>` +
`</li>`;
}
this.itemList.innerHTML = htmlToInsert;
}
reloadRecentActivities() {
const self = this;
const API = this.getContainer().getPlugin('api');
API.get(this.attributes['api'], {}, function(result) {
self.setEntries(result);
return false;
});
});
}
_reloadMenu(url)
{
this.fetch(url, {method: 'GET'})
.then(response => {
if (!response.ok) {
//this.menu.remove();
return;
}
return response.text().then(html => {
const newFormHtml = document.createElement('div');
newFormHtml.innerHTML = html;
this.menu.replaceWith(newFormHtml.firstElementChild);
this.menu = document.querySelector('header .notifications-menu');
this._attachAddRemoveFavorite();
});
})
.catch(() => {
//this.menu.remove();
});
}
}

View File

@@ -9,37 +9,57 @@
* [KIMAI] KimaiReducedClickHandler: abstract class
*/
import jQuery from 'jquery';
import KimaiPlugin from "../KimaiPlugin";
export default class KimaiReducedClickHandler extends KimaiPlugin {
_addClickHandler(selector, callback) {
jQuery('body').on('click', selector, function(event) {
// just in case an inner element is editable, than this should not be triggered
if (event.target.parentNode.isContentEditable || event.target.isContentEditable) {
/**
* No _underscore naming for now, as it would be mangled otherwise
* @param selector
* @param callback
*/
addClickHandler(selector, callback) {
document.body.addEventListener('click', (event) => {
// event.currentTarget is ALWAYS the body
let target = event.target;
while (target !== null) {
const tagName = target.tagName.toUpperCase();
if (tagName === 'BODY') {
return;
}
if (target.matches(selector)) {
break;
}
// when an element is clicked, which can trigger stuff itself, we don't want the event to be processed
if (tagName === 'A' || tagName === 'BUTTON' || tagName === 'INPUT' || tagName === 'LABEL') {
return;
}
target = target.parentNode;
}
if (target === null) {
return;
}
// handles the "click" on table rows or list elements
let target = event.target;
if (event.currentTarget.matches('tr') || event.currentTarget.matches('li')) {
while (target !== null && !target.matches('body')) {
// when an element within the row is clicked, that can trigger stuff itself, we don't want the event to be processed
// don't act if a link, button or form element was clicked
if (target.matches('a') || target.matches ('button') || target.matches ('input')) {
return;
}
target = target.parentNode;
}
// just in case an inner element is editable, then this should not be triggered
if (target.isContentEditable || target.parentNode.isContentEditable) {
return;
}
if (!target.matches(selector)) {
return;
}
event.preventDefault();
event.stopPropagation();
let href = jQuery(this).attr('data-href');
if (!href) {
href = jQuery(this).attr('href');
let href = target.dataset['href'];
if (href === undefined || href === null) {
href = target.href;
}
if (href === undefined || href === null || href === '') {

View File

@@ -1,150 +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] KimaiSelectDataAPI: <select> boxes with dynamic data from API
*/
import jQuery from 'jquery';
import KimaiPlugin from "../KimaiPlugin";
import moment from 'moment';
export default class KimaiSelectDataAPI extends KimaiPlugin {
constructor(selector) {
super();
this.selector = selector;
}
getId() {
return 'select-data-api';
}
init() {
this.activateApiSelects(this.selector);
}
activateApiSelects(selector) {
const self = this;
const API = this.getContainer().getPlugin('api');
jQuery('body').on('change', selector, function(event) {
const targetSelect = '#' + this.dataset['relatedSelect'];
// if the related target select does not exist, we do not need to load the related data
if (jQuery(targetSelect).length === 0) {
return;
}
let formPrefix = this.dataset['formPrefix'];
if (formPrefix === undefined || formPrefix === null) {
formPrefix = '';
} else if (formPrefix.length > 0) {
formPrefix += '_';
}
let newApiUrl = self._buildUrlWithFormFields(this.dataset['apiUrl'], formPrefix);
const selectValue = jQuery(this).val();
// Problem: select a project with activities and then select a customer that has no project
// results in a wrong URL, it triggers "activities?project=" instead of using the "emptyUrl"
if (selectValue === undefined || selectValue === null || selectValue === '' || (Array.isArray(selectValue) && selectValue.length === 0)) {
if (this.dataset['emptyUrl'] === undefined) {
self._updateSelect(targetSelect, {});
jQuery(targetSelect).attr('disabled', 'disabled');
return;
}
newApiUrl = self._buildUrlWithFormFields(this.dataset['emptyUrl'], formPrefix);
}
jQuery(targetSelect).removeAttr('disabled');
API.get(newApiUrl, {}, function(data){
self._updateSelect(targetSelect, data);
});
});
}
_buildUrlWithFormFields(apiUrl, formPrefix) {
let newApiUrl = apiUrl;
apiUrl.split('?')[1].split('&').forEach(item => {
const [key, value] = item.split('=');
const decoded = decodeURIComponent(value);
const test = decoded.match(/%(.*)%/);
if (test !== null) {
const targetField = jQuery('#' + formPrefix + test[1]);
let newValue = '';
if (targetField.length === 0) {
// happens for example:
// - in duration only mode, when the end field is not found
// console.log('ERROR: Cannot find field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
} else {
if (targetField.val() !== null) {
newValue = targetField.val();
if (newValue !== '') {
// having that special case here is far from being perfect... but for now it works ;-)
if (targetField.data('daterangepicker') !== undefined) {
if (key === 'begin' || key === 'start' || targetField.data('daterangepicker').singleDatePicker) {
newValue = targetField.data('daterangepicker').startDate.format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
} else if (key === 'end') {
newValue = targetField.data('daterangepicker').endDate.format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
}
} else if (targetField.data('format') !== undefined) {
if (moment(newValue, targetField.data('format')).isValid()) {
newValue = moment(newValue, targetField.data('format')).format(moment.HTML5_FMT.DATETIME_LOCAL_SECONDS);
}
}
} else {
// happens for example:
// - when the end date is not set on a timesheet record and the project list is loaded (as the URL contains the %end% replacer)
// console.log('Empty value found for field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
}
} else {
// happens for example:
// - when a customer without projects is selected
// console.log('ERROR: Empty field with name "' + test[1] + '" by selector: #' + formPrefix + test[1]);
}
}
if (Array.isArray(newValue)) {
newValue = newValue.join(',');
}
newApiUrl = newApiUrl.replace(value, newValue);
}
});
return newApiUrl;
}
_updateSelect(selectName, data) {
const options = {};
for (const apiData of data) {
let title = '__empty__';
if (apiData.hasOwnProperty('parentTitle') && apiData.parentTitle !== null) {
title = apiData.parentTitle;
}
if (!options.hasOwnProperty(title)) {
options[title] = [];
}
options[title].push(apiData);
}
const ordered = {};
Object.keys(options).sort().forEach(function(key) {
ordered[key] = options[key];
});
/** @var {KimaiFormSelect} select */
const select = this.getContainer().getPlugin('form-select');
select.updateOptions(selectName, ordered);
}
}

View File

@@ -9,93 +9,80 @@
* [KIMAI] KimaiThemeInitializer: initialize theme functionality
*/
import jQuery from 'jquery';
import { Tooltip } from 'bootstrap';
import KimaiPlugin from '../KimaiPlugin';
export default class KimaiThemeInitializer extends KimaiPlugin {
init() {
this.registerGlobalAjaxErrorHandler();
this.registerAutomaticAlertRemove('div.alert-success', 5000);
// activate the dropdown functionality
jQuery('.dropdown-toggle').dropdown();
// activate the tooltip functionality
jQuery('[data-toggle="tooltip"]').tooltip();
// activate all form plugins
this.getContainer().getPlugin('form').activateForm('.content-wrapper form', 'body');
this.getContainer().getPlugin('form').activateForm('form.searchform', 'body');
init()
{
// the tooltip do not use data-bs-toggle="tooltip" so they can be mixed with data-toggle="modal"
[].slice.call(document.querySelectorAll('[data-toggle="tooltip"]')).map(function (tooltipTriggerEl) {
return new Tooltip(tooltipTriggerEl);
});
this.registerModalAutofocus('#modal_search');
this.registerModalAutofocus('#remote_form_modal');
// activate all form plugins
/** @type {KimaiForm} FORMS */
const FORMS = this.getContainer().getPlugin('form');
FORMS.activateForm('div.page-wrapper form');
this._registerModalAutofocus('#remote_form_modal');
this.overlay = null;
// register a global event listener, which displays an overlays upon notification
document.addEventListener('kimai.reloadContent', (event) => {
// do not allow more than one loading screen at a time
if (this.overlay !== null) {
return;
}
// at which element we append the loading screen
let container = 'body';
if (event.detail !== undefined && event.detail !== null) {
container = event.detail;
}
const temp = document.createElement('div');
temp.innerHTML = '<div class="overlay"><div class="fas fa-sync fa-spin"></div></div>';
this.overlay = temp.firstElementChild;
document.querySelector(container).append(this.overlay);
});
// register a global event listener, which hides an overlay upon notification
document.addEventListener('kimai.reloadedContent', () => {
if (this.overlay !== null) {
this.overlay.remove();
this.overlay = null;
}
});
}
/**
* workaround for autofocus attribute, as the modal "steals" it
* Helps to set the autofocus on modals.
*
* @param {string} selector
*/
registerModalAutofocus(selector) {
let modal = jQuery(selector);
if (modal.length === 0) {
_registerModalAutofocus(selector) {
// on mobile you do not want to trigger the virtual keyboard upon modal open
if (this.isMobile()) {
return;
}
modal.on('shown.bs.modal', function () {
let form = modal.find('form');
let formAutofocus = form.find('[autofocus]');
const modal = document.querySelector(selector);
if (modal === null) {
return;
}
modal.addEventListener('shown.bs.modal', () => {
const form = modal.querySelector('form');
let formAutofocus = form.querySelectorAll('[autofocus]');
if (formAutofocus.length < 1) {
formAutofocus = form.find('input[type=text],textarea,select');
formAutofocus = form.querySelectorAll('input[type=text],input[type=date],textarea,select');
}
formAutofocus.filter(':not("[data-datetimepicker=on]")').filter(':visible:first').focus().delay(1000).focus();
});
}
/**
* redirect access denied / session timeouts to login page
*/
registerGlobalAjaxErrorHandler() {
const loginUrl = this.getConfiguration('login');
const alert = this.getContainer().getPlugin('alert');
const translation = this.getContainer().getTranslation().get('login.required');
jQuery(document).ajaxError(function(event, jqxhr, settings, thrownError) {
if (jqxhr.status !== undefined && jqxhr.status === 403) {
const loginRequired = jqxhr.getResponseHeader('login-required');
if (loginRequired !== null) {
alert.question(translation, function (result) {
if (result === true) {
window.location.replace(loginUrl);
}
});
}
if (formAutofocus.length > 0) {
formAutofocus[0].focus();
}
});
}
/**
* auto hide success messages, as they are just meant as user feedback and not as a permanent information
*
* @param {string} selector
* @param {integer} interval
*/
registerAutomaticAlertRemove(selector, interval) {
const self = this;
this._alertRemoveHandler = setInterval(
function() {
self.hideAlert(selector);
},
interval
);
}
unregisterAutomaticAlertRemove() {
clearInterval(this._alertRemoveHandler);
}
/**
* @param {string} selector
*/
hideAlert(selector) {
jQuery(selector).alert('close');
}
}

View File

@@ -9,15 +9,14 @@
* [KIMAI] KimaiToolbar: some event listener to handle the toolbar/data-table filter, toolbar and navigation
*/
import jQuery from 'jquery';
import KimaiPlugin from "../KimaiPlugin";
export default class KimaiToolbar extends KimaiPlugin {
constructor(formSelector, formSubmitActionClass) {
super();
this.formSelector = formSelector;
this.actionClass = formSubmitActionClass;
this._formSelector = formSelector;
this._actionClass = formSubmitActionClass;
}
getId() {
@@ -26,50 +25,52 @@ export default class KimaiToolbar extends KimaiPlugin {
init() {
const formSelector = this.getSelector();
const self = this;
const EVENT = self.getContainer().getPlugin('event');
this._registerPagination(formSelector, EVENT);
this._registerSortableTables(formSelector, EVENT);
this._registerAlternativeSubmitActions(formSelector, this.actionClass);
this._registerPagination(formSelector);
this._registerSortableTables(formSelector);
this._registerAlternativeSubmitActions(formSelector, this._actionClass);
// Reset the page if filter values are changed, otherwise we might end up with a limited set of data,
// which does not support the given page - and it would be just wrong to stay in the same page
jQuery(formSelector +' input').change(function (event) {
switch (event.target.id) {
case 'order':
case 'orderBy':
case 'page':
break;
default:
jQuery(formSelector + ' input#page').val(1);
break;
}
self.triggerChange();
[].slice.call(document.querySelectorAll(formSelector + ' input')).map((element) => {
element.addEventListener('change', (event) => {
switch (event.target.id) {
case 'order':
case 'orderBy':
case 'page':
break;
default:
document.querySelector(formSelector + ' input#page').value = 1;
break;
}
});
this.triggerChange();
});
// when user selected a new customer or project, reset the pagination back to 1
// and then find out if the results should be reloaded
jQuery(formSelector + ' select').change(function (event) {
let reload = true;
switch (event.target.id) {
case 'customer':
if (jQuery(formSelector + ' select#project').length > 0) {
reload = false;
}
break;
[].slice.call(document.querySelectorAll(formSelector + ' select')).map((element) => {
element.addEventListener('change', (event) => {
let reload = true;
switch (event.target.id) {
case 'customer':
if (document.querySelector(formSelector + ' select#project') !== null) {
reload = false;
}
break;
case 'project':
if (jQuery(formSelector + ' select#activity').length > 0) {
reload = false;
}
break;
}
jQuery(formSelector + ' input#page').val(1);
case 'project':
if (document.querySelector(formSelector + ' select#activity') !== null) {
reload = false;
}
break;
}
document.querySelector(formSelector + ' input#page').value = 1;
if (reload) {
self.triggerChange();
}
if (reload) {
this.triggerChange();
}
});
});
}
@@ -80,15 +81,17 @@ export default class KimaiToolbar extends KimaiPlugin {
_registerAlternativeSubmitActions(toolbarSelector, actionBtnClass) {
document.addEventListener('click', function(event) {
let target = event.target;
while (target !== null && !target.matches('body')) {
while (target !== null && typeof target.matches === "function" && !target.matches('body')) {
if (target.classList.contains(actionBtnClass)) {
const form = document.querySelector(toolbarSelector);
if (form === null) {
return;
}
const prevAction = form.action;
const prevMethod = form.method;
form.target = '_blank';
const prevAction = form.getAttribute('action');
const prevMethod = form.getAttribute('method');
if (target.dataset.target !== undefined) {
form.target = target.dataset.target;
}
form.action = target.href;
if (target.dataset.method !== undefined) {
form.method = target.dataset.method;
@@ -104,51 +107,65 @@ export default class KimaiToolbar extends KimaiPlugin {
target = target.parentNode;
}
});
});
}
/**
* Sortable datatables use hidden fields in the toolbar filter/search form
* @private
*/
_registerSortableTables(formSelector, EVENT) {
jQuery('body').on('click', 'th.sortable', function(event){
var $header = jQuery(event.target);
var order = 'DESC';
var orderBy = $header.data('order');
if ($header.hasClass('sorting_desc')) {
_registerSortableTables(formSelector) {
document.body.addEventListener('click', (event) => {
if (!event.target.matches('th.sortable')) {
return;
}
let order = 'DESC';
let orderBy = event.target.dataset['order'];
if (event.target.classList.contains('sorting_desc')) {
order = 'ASC';
}
jQuery(formSelector + ' #orderBy').val(orderBy);
jQuery(formSelector + ' #order').val(order);
document.querySelector(formSelector + ' #orderBy').value = orderBy;
document.querySelector(formSelector + ' #order').value = order;
// re-render the selectboxes
jQuery(formSelector + ' #orderBy').trigger('change');
jQuery(formSelector + ' #order').trigger('change');
// re-render the selectbox
document.querySelector(formSelector + ' #orderBy').dispatchEvent(new Event('change'));
document.querySelector(formSelector + ' #order').dispatchEvent(new Event('change'));
// triggers the datatable reload - search for the event name
EVENT.trigger('filter-change');
document.dispatchEvent(new Event('filter-change'));
});
}
/**
* This catches all clicks on the pagination and prevents the default action, as we want to reload the page via JS
* This catches all clicks on the pagination and prevents the default action,
* as we want to reload the page via JS.
*
* @private
*/
_registerPagination(formSelector, EVENT) {
jQuery('body').on('click', 'div.navigation ul.pagination li a', function(event) {
let pager = jQuery(formSelector + " input#page");
if (pager.length === 0) {
_registerPagination(formSelector) {
document.body.addEventListener('click', (event) => {
if (!event.target.matches('ul.pagination li a') && (event.target.parentNode === null || !event.target.parentNode.matches('ul.pagination li a'))) {
return;
}
let pager = document.querySelector(formSelector + " input#page");
if (pager === null) {
return;
}
let target = event.target;
// this happens for the arrows, which can be an icon <i> element
if (!target.matches('a')) {
target = target.parentNode;
}
event.preventDefault();
event.stopPropagation();
let urlParts = jQuery(this).attr('href').split('/');
let page = urlParts[urlParts.length-1];
pager.val(page);
pager.trigger('change');
EVENT.trigger('pagination-change');
let urlParts = target.href.split('/');
pager.value = urlParts[urlParts.length-1];
pager.dispatchEvent(new Event('change'));
document.dispatchEvent(new Event('pagination-change'));
return false;
});
@@ -158,7 +175,7 @@ export default class KimaiToolbar extends KimaiPlugin {
* Triggers an event, that everyone can listen for.
*/
triggerChange() {
this.getContainer().getPlugin('event').trigger('toolbar-change');
document.dispatchEvent(new Event('toolbar-change'));
}
/**
@@ -167,7 +184,7 @@ export default class KimaiToolbar extends KimaiPlugin {
* @returns {string}
*/
getSelector() {
return this.formSelector;
return this._formSelector;
}
}

View 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);
});
}
}

View 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';
}
}

View 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('"', '&quot;') + '"';
}
}
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);
});
});
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
});
});
}
}

View File

@@ -10,25 +10,18 @@
@import 'error-page';
@import 'print';
@import 'content';
@import 'box';
@import 'content-header';
@import 'toolbar';
@import 'sidebar';
@import 'footer';
@import 'datatable';
@import 'datatable-extensions';
@import 'loading-spinner';
@import 'tables';
@import 'calendar';
@import 'dashboard';
@import 'navbar';
@import 'daterangepicker';
@import 'skins';
@import 'ticktac';
@import 'sweetalert';
@import 'autocomplete';
@import 'selectpicker';
@import 'reporting';
@import 'forms';
@import 'modal';
@import 'progressbar';
@import 'avatar';
@import 'security';
@import 'tabs';
@import 'theme-dark';
@import 'pages';
@import 'tabler-fixes';
@import 'help';
@import 'rtl';

View File

@@ -1,76 +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.
*/
.ui-widget {
font-size: 1em/*{fsDefault}*/;
}
.ui-widget.ui-widget-content {
border: 1px solid #c5c5c5/*{borderColorDefault}*/;
}
.ui-widget-content {
border: 1px solid #dddddd/*{borderColorContent}*/;
background: #ffffff/*{bgColorContent}*/ /*{bgImgUrlContent}*/ /*{bgContentXPos}*/ /*{bgContentYPos}*/ /*{bgContentRepeat}*/;
color: #333333/*{fcContent}*/;
}
.ui-menu {
list-style: none;
padding: 0;
margin: 0;
display: block;
outline: 0;
}
.ui-menu .ui-menu-item {
margin: 0;
cursor: pointer;
}
.ui-autocomplete {
position: absolute;
top: 0;
left: 0;
cursor: default;
}
.ui-front {
z-index: 1200;
}
.ui-menu .ui-menu-item-wrapper {
position: relative;
padding: 3px 1em 3px .4em;
}
.ui-menu .ui-state-focus,
.ui-menu .ui-state-active {
margin: -1px;
}
.ui-state-active,
.ui-widget-content .ui-state-active,
.ui-widget-header .ui-state-active,
a.ui-button:active,
.ui-button:active,
.ui-button.ui-state-active:hover {
border: 1px solid #003eff/*{borderColorActive}*/;
background: #007fff/*{bgColorActive}*/ /*{bgImgUrlActive}*/ /*{bgActiveXPos}*/ /*{bgActiveYPos}*/ /*{bgActiveRepeat}*/;
font-weight: normal/*{fwDefault}*/;
color: #ffffff/*{fcActive}*/;
}
.ui-icon-background,
.ui-state-active .ui-icon-background {
border: #003eff/*{borderColorActive}*/;
background-color: #ffffff/*{fcActive}*/;
}
.ui-state-active a,
.ui-state-active a:link,
.ui-state-active a:visited {
color: #ffffff/*{fcActive}*/;
text-decoration: none;
}

View File

@@ -1,22 +1,9 @@
$avatar-base-size: 30;
.avatar {
text-align: center;
background-color: #d2d6de;
border-radius: 50%;
display: inline-block;
height: ($avatar-base-size)+px;
width: ($avatar-base-size)+px;
vertical-align: middle;
}
.initials {
line-height: 1;
position: relative;
font-weight: bold;
font-size: ceil($avatar-base-size / 2 - 2)+px;
top: floor($avatar-base-size / 4 - 1)+px;
}
$avatarSizes: "xs" .75, "sm" 1.25, "md" 1.5, "lg" 2;
@@ -28,7 +15,6 @@ $avatarSizes: "xs" .75, "sm" 1.25, "md" 1.5, "lg" 2;
.initials {
font-size: ceil($avatar-base-size * $avatarBaseSize / 2 - 2)+px;
top: ceil($avatar-base-size * $avatarBaseSize / 4 - 1)+px;
}
}
}
@@ -41,7 +27,6 @@ $avatarSizes: "xs" .75, "sm" 1.25, "md" 1.5, "lg" 2;
height: ceil($avatar-base-size * 2.75)+px;
.initials {
font-size: ceil($avatar-base-size * 2.75 / 2 - 2)+px;
top: ceil($avatar-base-size * 2.75 / 4 - 1)+px;
}
}
}
@@ -54,7 +39,3 @@ table.dataTable.table > tbody > tr > td {
}
}
}
.avatar.teamlead {
box-shadow: 0px 0px 5px 1px rgba(0,0,0,.4),0 0px 3px 0 rgba(0,0,0,0.3);
}

View File

@@ -1,22 +0,0 @@
.box {
/* used on detail pages for: customer, project and activity */
.box-body.no-padding div.comment,
.box-body.no-padding .box-padding {
padding: 10px;
}
.box-tools {
/* a button that is directly connected with a pagination element inside the box tools (eg. project list on customer detail page) */
.btn-pager {
float: left;
margin-right: 5px;
}
}
}
#customer_details_box,
#project_details_box,
#activity_details_box {
th {
width: 30%;
}
}

View File

@@ -1,51 +1,15 @@
.popover-content {
.calendar-entry {
ul {
padding: 0;
list-style-type: none;
}
.calendar-entry {
ul {
padding: 0;
list-style-type: none;
}
}
/* Drag and Drop events with styling */
.external-events {
.external-event {
vertical-align: middle;
padding: 0 !important;
display: inline-block;
position: relative;
width: 100%;
}
.subtitle {
padding: 0 7px 4px 7px;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 0.7em;
}
.title {
padding: 3px 7px 0;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.draggable {
cursor: grab;
}
.fc-bootstrap3 {
.table-bordered > tbody > tr > td,
.table-bordered > thead > tr > td,
.table-bordered > thead > tr > th {
border: 1px solid #ddd;
}
.fc-head {
background-color: #fafafa;
}
td.fc-today {
background-color: #fcf8e3!important;
}
}
#timesheet_calendar {
--bs-gray-400: var(--tblr-border-color);
--bs-gray-200: var(--tblr-gray-200);
}

View File

@@ -1,65 +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.
*/
.content-header {
height: 50px;
border-top: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1) !important;
background-color: #fff !important;
padding: 10px 0 0 10px;
h1 {
padding-top: 3px;
float: left;
small {
display: none;
}
}
}
/* Page based action buttons in the upper right corner of the content area */
.content-header>.breadcrumb {
position: absolute;
float: right;
background: transparent;
top: 0;
right: 0;
padding-left: 10px;
.btn {
span.label {
position: absolute;
top: 1px;
right: 1px;
text-align: center;
font-size: 9px;
padding: 2px 3px;
}
}
}
@media (max-width: $screen-md-max) {
.content-header h1 {
font-size: 1.5em;
}
.content-header>.breadcrumb {
margin-top: 0;
}
}
@media (min-width: $screen-sm-min) {
.content-header>.breadcrumb {
right: 10px;
}
.content-header {
h1 {
small {
display: inline-block;
}
}
}
}

View File

@@ -5,20 +5,16 @@
* file that was distributed with this source code.
*/
.content {
padding: 15px 0;
/* make sure that elements can be hidden without taking space (eg. hidden modals like the search or column visibility) */
.hidden-no-space {
margin: 0;
padding: 0;
}
@media (min-width: $screen-sm-min) {
.content {
padding: 15px;
}
}
@media (min-width: $screen-lg-min) {
.content {
padding: 20px;
}
/* used at least in the duration dropdown */
.pre-scrollable {
max-height: 340px;
overflow-y: scroll;
}
td {
@@ -30,16 +26,11 @@ td {
margin: 0;
}
}
}
/* ABOUT SCREEN */
.box-body .menu-icon {
width: 35px;
height: 35px;
border-radius: 50%;
text-align: center;
line-height: 35px;
margin-right: 15px;
/* table cells that contain badges (like the team or timesheet view)
and are multi-line entries the badges do not have margin between them */
&.badges {
line-height: 22px;
}
}
.label-gray {
@@ -47,11 +38,6 @@ td {
color: #666;
}
/* Delete link in action dropdowns */
.dropdown-menu > li.delete > a {
color: #dd4b39;
}
.open-edit {
cursor: pointer;
}
@@ -66,17 +52,46 @@ table.dataTable thead > tr > th.hw-min {
width: 1%;
white-space: normal;
}
.badge {
font-weight: normal;
color: #000;
background-color: $kimai-default;
/* If a table column contains ONLY avatar <img> it will collapse, so make it a defined width */
.w-avatar {
width: 40px;
img.avatar {
/* a weird bug in tables with real avatar <img> only causes them to collapse */
max-width: 40px;
}
}
.label {
font-weight: normal;
.list-group-item{
/* !important rules, because dark theme overwrites list-group-item */
&.danger {
padding-left: $list-group-item-padding-x - 0.2rem;
border-left: 0.2rem solid $danger!important;
}
&.success {
padding-left: $list-group-item-padding-x - 0.2rem;
border-left: 0.2rem solid $success!important;;
}
}
a.link-black {
color: #000;
}
/* Scrollable boxes (eg. widgets on dashboard) */
.box-body-scrollable {
overflow: auto;
max-height: 340px;
}
.card {
.card-tools {
/* a button that is directly connected with a pagination element inside the box tools (eg. project list on customer detail page) */
.btn-pager {
float: left;
margin-right: 5px;
}
}
}
#customer_details_box,
#project_details_box,
#activity_details_box {
th {
width: 30%;
}
}

View File

@@ -1,20 +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.
*/
section {
&.dashboard {
.info-box {
.info-box-text {
white-space: normal;
}
}
}
.box-body-scrollable {
overflow: auto;
max-height: 300px;
}
}

View File

@@ -1,265 +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.
*/
/* Loading indicator: overwritten from AdminLTE for positioning and supporting fas classes */
.box .overlay > .fas,
section.content .overlay > .fas,
.overlay-wrapper .overlay > .fas {
position: absolute;
top: 50vh;
left: 50vw;
margin-left: -15px;
margin-top: -15px;
color: #000;
font-size: 30px;
}
.box > .overlay,
.overlay-wrapper > .overlay,
section.content > .overlay,
.box > .loading-img,
.overlay-wrapper > .loading-img {
position: fixed; /* see #1330 */
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.box .overlay,
section.content .overlay,
.overlay-wrapper .overlay {
z-index: 50;
background: rgba(255, 255, 255, 0.7);
border-radius: $box-border-radius;
}
.box .overlay.dark,
section.content .overlay.dark,
.overlay-wrapper .overlay.dark {
background: rgba(0, 0, 0, 0.5);
}
/*
dataTables.bootstrap.css
*/
div.dataTables_length label {
font-weight: normal;
text-align: left;
white-space: nowrap;
}
div.dataTables_length select {
width: 75px;
display: inline-block;
}
div.dataTables_filter {
text-align: right;
}
div.dataTables_filter label {
font-weight: normal;
white-space: nowrap;
text-align: left;
}
div.dataTables_filter input {
margin-left: 0.5em;
display: inline-block;
width: auto;
}
div.dataTables_info {
padding-top: 8px;
white-space: nowrap;
}
div.dataTables_paginate {
margin: 0;
white-space: nowrap;
text-align: right;
}
div.dataTables_paginate ul.pagination {
margin: 2px 0;
white-space: nowrap;
}
@media screen and (max-width: 767px) {
div.dataTables_length,
div.dataTables_filter,
div.dataTables_info,
div.dataTables_paginate {
text-align: center;
}
div.DTTT {
margin-bottom: 0.5em;
}
}
table.dataTable td,
table.dataTable th {
-webkit-box-sizing: content-box;
-moz-box-sizing: content-box;
box-sizing: content-box;
}
table.dataTable {
clear: both;
/*margin-top: 6px !important;
margin-bottom: 6px !important;*/
margin-bottom: 0px !important;
max-width: none !important;
}
table.dataTable thead .sorting,
table.dataTable thead .sorting_asc,
table.dataTable thead .sorting_desc,
table.dataTable thead .sorting_asc_disabled,
table.dataTable thead .sorting_desc_disabled {
cursor: pointer;
position: relative;
}
table.dataTable thead .sorting_asc,
table.dataTable thead .sorting_desc {
font-weight: bold;
}
table.dataTable thead .sorting:after,
table.dataTable thead .sorting_asc:after,
table.dataTable thead .sorting_desc:after {
padding-left: 5px;
font-family: 'Font Awesome 5 Free';
opacity: 0.5;
font-size: 12px;
}
table.dataTable thead .sorting:after {
opacity: 0.2;
content: "\f0dc"; /* sort */
}
table.dataTable thead .sorting_asc:after {
content: "\f062";
}
table.dataTable thead .sorting_desc:after {
content: "\f063";
}
div.dataTables_scrollBody table.dataTable thead .sorting:before,
div.dataTables_scrollBody table.dataTable thead .sorting_asc:before,
div.dataTables_scrollBody table.dataTable thead .sorting_desc:before {
display: none;
}
table.dataTable thead .sorting_asc_disabled:before,
table.dataTable thead .sorting_desc_disabled:before {
color: #eee;
}
table.dataTable thead > tr > th {
vertical-align: top;
white-space: nowrap;
}
table.dataTable th:active {
outline: none;
}
/* Condensed */
table.dataTable.table-condensed thead > tr > th {
padding-right: 20px;
}
table.dataTable.table-condensed thead .sorting:before,
table.dataTable.table-condensed thead .sorting_asc:before,
table.dataTable.table-condensed thead .sorting_desc:before {
top: 6px;
right: 6px;
}
/* Scrolling */
div.dataTables_scrollHead table {
margin-bottom: 0 !important;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
div.dataTables_scrollHead table thead tr:last-child th:first-child,
div.dataTables_scrollHead table thead tr:last-child td:first-child {
border-bottom-left-radius: 0 !important;
border-bottom-right-radius: 0 !important;
}
div.dataTables_scrollBody table {
border-top: none;
margin-top: 0 !important;
margin-bottom: 0 !important;
}
div.dataTables_scrollBody tbody tr:first-child th,
div.dataTables_scrollBody tbody tr:first-child td {
border-top: none;
}
div.dataTables_scrollFoot table {
margin-top: 0 !important;
border-top: none;
}
/* Frustratingly the border-collapse:collapse used by Bootstrap makes the column
width calculations when using scrolling impossible to align columns. We have
to use separate
*/
table.table-bordered.dataTable {
border-collapse: separate !important;
}
table.table-bordered thead th,
table.table-bordered thead td {
border-left-width: 0;
border-top-width: 0;
}
table.table-bordered tbody th,
table.table-bordered tbody td {
border-left-width: 0;
border-bottom-width: 0;
}
table.table-bordered tfoot th,
table.table-bordered tfoot td {
border-left-width: 0;
border-bottom-width: 0;
}
table.table-bordered th:last-child,
table.table-bordered td:last-child {
border-right-width: 0;
}
div.dataTables_scrollHead table.table-bordered {
border-bottom-width: 0;
}
/*
* TableTools styles
*/
.table.dataTable tbody tr.active td,
.table.dataTable tbody tr.active th {
background-color: #08C;
color: white;
}
.table.dataTable tbody tr.active:hover td,
.table.dataTable tbody tr.active:hover th {
background-color: #0075b0 !important;
}
.table.dataTable tbody tr.active th > a,
.table.dataTable tbody tr.active td > a {
color: white;
}
.table-striped.dataTable tbody tr.active:nth-child(odd) td,
.table-striped.dataTable tbody tr.active:nth-child(odd) th {
background-color: #017ebc;
}

View File

@@ -1,13 +0,0 @@
@import '~daterangepicker/daterangepicker.css';
.drp-calendar {
.calendar-table {
td.today:not(.active) {
background-color: lighten(#357ebd, 40);
color: #357ebd;
}
select.yearselect{
width: unset;
}
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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.
*/
body {
font-family: sans-serif;
font-size: 10pt;
margin: 0;
padding: 0;
}
p {
margin: 0;
}
table.items {
border: 0.1mm solid #000000;
width: 100%;
font-size: 9pt;
border-collapse: collapse;
}
td, th {
padding: 7px;
}
td {
vertical-align: top;
}
.items td {
border-left: 0.1mm solid #000000;
border-right: 0.1mm solid #000000;
}
.items tr.even {
background-color: #f5f5f5;
}
.items tr.summary {
background-color: #efefef;
}
.items tr.summary td {
font-weight: bold;
border-top: 0.1mm solid #000000;
border-bottom: 0.1mm solid #000000;
}
table thead th {
background-color: #ececec;
border: 0.1mm solid #000000;
font-weight: bold;
font-size: 10pt;
text-align: left;
}
.items td.totals {
font-weight: bold;
border: 0.1mm solid #000000;
}
.items .center,
.items td.duration,
.items td.cost {
text-align: center;
}
.text-nowrap {
white-space: nowrap;
}

View File

@@ -10,3 +10,10 @@ $fa-font-path: "~@fortawesome/fontawesome-free/webfonts/";
@import '~@fortawesome/fontawesome-free/scss/regular';
@import '~@fortawesome/fontawesome-free/scss/solid';
@import '~@fortawesome/fontawesome-free/scss/brands';
/* all icon-buttons with fontawesome icons */
.btn-icon i.icon {
font-size: 16px;
color: $text-muted;
padding-top: 2px;
}

View File

@@ -1,11 +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.
*/
footer.main-footer {
padding: 5px;
font-size: 80%;
}

View File

@@ -1,67 +1,38 @@
/* Currently only used in batch-update form for timesheets, to highlight sections of combined fields */
form .box-body fieldset {
border-radius: $box-border-radius;
padding: 10px;
&:hover {
background-color: $highlight-bg;
/* bootstrap thead.sticky-top has z-index 1020 and the reporting form dropdowns hide behind that */
.checkbox-menu.show {
z-index: 1021;
}
/* make sure that at least xx:yy fits into the widget */
.duration-widget {
.input-group {
min-width: 110px;
}
input.duration-input {
min-width: 55px;
}
}
/* To be used with .form-horizontal - to reduce the space between rows, eg. used in the search modals */
form.form-narrow {
.form-group {
margin: 0 0 5px 0;
#report-form {
div.btn-list {
width: 100%;
div.selectpicker {
min-width: 200px;
max-width: 400px;
}
li.dropdown-item {
.form-check {
margin-bottom: 0;
}
}
}
}
/* Mark as exported checkbox in export and invoice create screen */
.form-horizontal {
.checkbox {
min-height: 20px;
}
}
/* bootstrap3 hack because the filter look plain ugly without it (see report: project month) */
.checkbox-menu li label {
display: block;
padding: 5px 15px 5px 10px !important;
clear: both;
font-weight: normal;
line-height: 1.42857143;
color: #333;
white-space: nowrap;
margin:0;
transition: background-color .4s ease;
}
.checkbox-menu li div.checkbox {
display: block;
width: 100%;
}
.checkbox-menu li div.radio {
display: block;
width: 100%;
}
.checkbox-menu li input {
margin: 0 5px !important;
position: relative;
}
.checkbox-menu li.active label {
background-color: #cbcbff;
font-weight:bold;
}
.checkbox-menu li label:hover,
.checkbox-menu li label:focus {
background-color: #f5f5f5;
}
.checkbox-menu li.active label:hover,
.checkbox-menu li.active label:focus {
background-color: #b8b8ff;
}
.duration-input {
min-width:50px;
padding: 6px;
.color-choice-item {
width: 20px;
height: 20px;
display: inline-block;
margin-right: 10px;
border-radius: var(--tblr-border-radius);
}

36
assets/sass/help.scss Normal file
View File

@@ -0,0 +1,36 @@
/*
* 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.
*/
.float-help {
position: fixed;
width: 50px;
height: 50px;
border-radius: 50px;
bottom: 20px;
right: 20px;
background-color: var(--tblr-body-color);
text-align: center;
z-index: 1021;
/* box-shadow: 2px 2px 3px var(--tblr-body-color); */
i {
margin-top: 11px;
color: var(--tblr-body-bg);
font-size: 30px;
}
}
@include media-breakpoint-down(md) {
.float-help {
width: 36px;
height: 36px;
border-radius: 36px;
i {
margin-top: 8px;
font-size: 20px;
}
}
}

View File

@@ -8,7 +8,7 @@
body {
font-family: sans-serif;
font-size: 10pt;
margin: 0;
margin: 4px;
padding: 0;
}
table, tr, td, th, p, h1, h2, h3, h4, h5 {
@@ -44,14 +44,10 @@ p {
white-space: nowrap;
}
.date {
color: #666;
font-size: 80%;
text-align: right;
padding-top: 6px;
}
.wrapper {
margin: 4px;
}
table {
width: 100%;
border-spacing: 0;

View File

@@ -5,19 +5,21 @@
* file that was distributed with this source code.
*/
@import "~bootstrap-sass/assets/stylesheets/bootstrap/variables";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/mixins";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/normalize";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/print";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/scaffolding";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/type";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/grid";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/tables";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/list-group";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/panels";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/wells";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/utilities";
@import "~bootstrap-sass/assets/stylesheets/bootstrap/responsive-utilities";
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/maps";
@import "~bootstrap/scss/mixins";
//@import "~bootstrap/scss/normalize";
//@import "~bootstrap/scss/print";
//@import "~bootstrap/scss/scaffolding";
@import "~bootstrap/scss/reboot";
@import "~bootstrap/scss/type";
@import "~bootstrap/scss/grid";
@import "~bootstrap/scss/tables";
@import "~bootstrap/scss/list-group";
@import "~bootstrap/scss/card";
@import "~bootstrap/scss/utilities";
//@import "~bootstrap/scss/responsive-utilities";
body {
font-family: $font-family-sans-serif;

View File

@@ -1,8 +1,38 @@
@media (max-width: $screen-sm-max) {
.main-header .logo {
display: none;
.inline-search {
max-width: 200px;
/* the entire inline-search should actually use .input-group-flat, but that causes some weird css problems inside the dropdown menu */
#searchTerm {
border-right: 0;
}
.fixed .content-wrapper, .fixed .right-side, .control-sidebar, .main-sidebar {
padding-top: 50px;
}
@media (min-width: 360px) {
.inline-search {
max-width: 235px;
}
}
}
@include media-breakpoint-up(md) {
.inline-search {
max-width: 325px;
}
.search-dropdown {
width: 500px;
}
}
/* Format the company title in the left navigation, as Tabler is optimized for an image only */
h1.navbar-brand a {
span {
display: inline-block;
padding-top: 0.5rem;
}
&:hover {
text-decoration: none;
}
}
.page-actions .dropdown-menu,
#export-buttons .dropdown-menu,
.inline-search .dropdown-menu {
/* bootstrap thead.sticky-top has z-index 1020 and the reporting form dropdowns hide behind that */
z-index: 1021;
}

View File

@@ -0,0 +1,25 @@
/*
* 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.
*/
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 50;
background: rgba(255, 255, 255, 0.7);
> .fas {
position: absolute;
top: 50vh;
left: 50vw;
margin-left: -15px;
margin-top: -15px;
color: #000;
font-size: 30px;
}
}

View File

@@ -1,9 +1,3 @@
.modal-header,
.modal-footer {
padding-top: 10px;
padding-bottom: 10px;
}
.modal-content {
border-radius: 3px;
box-shadow: 0 10px 80px rgba(0, 0, 0, 0.6);
}

View File

@@ -1,153 +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.
*/
.navbar-custom-menu {
.navbar-nav {
li>.ddt-small {
padding-top: 10px;
padding-bottom: 10px;
}
}
}
.navbar-nav {
.avatar {
max-height: 30px;
max-width: 30px;
height: 30px;
width: 30px;
margin-top: 0;
.initials {
top: 5px;
left: 0;
}
}
li>.ddt-small {
padding-top: 10px;
padding-bottom: 10px;
}
li.messages-menu ul.menu li {
a:hover .pull-left i {
color: #dd4b39;
}
a .pull-left i {
color: #444;
}
}
.messages-menu>.dropdown-menu>li .menu>li>a>p {
overflow: hidden;
text-overflow: ellipsis;
}
.messages-menu>.dropdown-menu>li .menu>li>a>h4>span {
margin-right: 55px;
}
.dropdown-menu {
box-shadow: 0 8px 17px 0 rgba(0, 0, 0, 0.2), 0 6px 20px 0 rgba(0, 0, 0, 0.19);
max-width: 90vw;
width: unset !important;
}
.user-menu {
ul {
li {
a {
color: #444;
&:hover {
background-color: #f4f4f4;
}
i {
padding-right: 10px;
}
}
hr {
margin: 0;
}
}
}
}
}
@keyframes ticktac-blink {
0% { opacity: 1; }
5% { opacity: 0.95; }
10% { opacity: 0.9; }
15% { opacity: 0.85; }
20% { opacity: 0.8; }
25% { opacity: 0.75; }
30% { opacity: 0.7; }
35% { opacity: 0.65; }
40% { opacity: 0.6; }
45% { opacity: 0.65; }
50% { opacity: 0.7; }
55% { opacity: 0.75; }
60% { opacity: 0.8; }
65% { opacity: 0.85; }
70% { opacity: 0.9; }
75% { opacity: 0.95; }
100% { opacity: 1; }
}
.ticktac-single {
display: block;
position: relative;
padding-left: 15px;
padding-right: 15px;
i {
animation: ticktac-blink 2s step-end infinite;
}
span {
font-size: 1.8em;
line-height: 1em;
padding-left: 5px;
padding-right: 0;
}
&:hover {
background: rgba(0,0,0,.3);
}
}
body.fixed {
.navbar-nav {
.user-menu {
margin-right: 10px;
}
}
}
@media (max-width: $screen-sm-max) {
.navbar-custom-menu>.navbar-nav>li>.dropdown-menu {
right: 0;
}
body.fixed {
.navbar-nav {
.user-menu {
margin-right: 0;
}
}
}
.navbar-nav .user-menu.open .dropdown-menu > li > a {
padding: 5px 25px 5px 25px
}
}
@media (max-width: $screen-sm-max) {
.ticktac-single {
padding-left: 5px;
padding-right: 5px;
}
.navbar-custom-menu .navbar-nav li>.ddt-small {
padding-left: 5px;
padding-right: 5px;
}
}
@media (max-width: $screen-md-max) {
.ticktac-single {
padding-left: 10px;
padding-right: 10px;
}
.navbar-custom-menu .navbar-nav li>.ddt-small {
padding-left: 10px;
padding-right: 10px;
}
}

39
assets/sass/pages.scss Normal file
View File

@@ -0,0 +1,39 @@
/*
* 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.
*/
/*
----------- This file contains rules that only page to certain pages -----------
*/
/* weekly working times AKA quick-entries form */
section.quick-entry-page {
#quick_entry_form {
display: none;
}
/* shrinks the dropdown elements for project and activity to fit into the table, instead of adjusting to the longest option element */
.ts-wrapper {
display: table;
table-layout: fixed;
}
/* make the duration field and dropdown smaller */
#quick_entry_box {
.duration-widget {
.input-group {
min-width: 85px;
}
.btn-duration-preset {
padding: 7px 8px 7px 5px;
}
input {
padding: 7px 3px 7px 7px;
}
}
}
}

View File

@@ -12,7 +12,7 @@
.col-print-12{
width:100%;
}
.sidebar-mini.sidebar-collapse .content-wrapper, .sidebar-mini.sidebar-collapse .main-footer, .sidebar-mini.sidebar-collapse .right-side {
.content-wrapper {
margin: 0!important;
}
section.content {

View File

@@ -13,7 +13,6 @@
.progress-number {
display: inline-block;
width: 98%;
text-align: right;
float: none;
}
}

View File

@@ -1,7 +0,0 @@
.form-reporting .box-header .form-group {
margin-right: 10px;
label {
font-size: $font-size-base;
}
}

View File

@@ -5,5 +5,12 @@
* file that was distributed with this source code.
*/
$icon-font-path: "~bootstrap-sass/assets/fonts/bootstrap/";
@import '~bootstrap-sass/assets/stylesheets/bootstrap';
html[dir=rtl] {
.fa-chevron-left:before {
content: "\f054";
}
.fa-chevron-right:before {
content: "\f053";
}
}

View File

@@ -1,35 +0,0 @@
body.login-page {
.alert {
margin-bottom: 0;
}
.login-box-body {
text-align: center;
padding: 30px 35px;
box-shadow: 0 10px 30px 0 rgba(172, 168, 168, 0.43);
.btn {
padding: 7px;
font-size: 115%;
}
}
.login-head {
margin-top: 0;
}
.login-actions a {
display: block;
}
.form-group {
input[type=password], input[type=text] {
padding: 20px 15px;
}
}
span.glyphicon {
display: none;
}
}
@media (max-width: $screen-sm-max) {
body.login-page {
.login-box-body {
padding: 20px 27px;
}
}
}

View File

@@ -1,29 +1,26 @@
@import '~select2/dist/css/select2.min.css';
@import '~select2-bootstrap-theme/dist/select2-bootstrap.min.css';
@import '~tom-select/dist/scss/tom-select.bootstrap5';
/* some labels were placed incorrect due to a margin-top: -4px */
.select2-container .select2-selection--single .select2-selection__rendered {
margin-top: 0;
}
/* make sure the border looks like all other form elements */
.select2-container--bootstrap .select2-selection {
border-radius: 0;
}
/* placeholder in multi-selects were cut off */
.select2-search.select2-search--inline .select2-search__field {
width: 100% !important;
}
/* make sure that <optgroup> elements are padded, to allow visual difference to global activities */
.select2-container--bootstrap .select2-results__options.select2-results__options--nested li.select2-results__option {
padding-left: 30px;
}
/* empty <option> were too small to be noticed as clickable element */
.select2-container--bootstrap .select2-results__option {
min-height: 30px;
/* bootstrap thead.sticky-top has z-index 1020 and tomselect dropdowns often hide behind that */
.ts-dropdown {
z-index: 1021;
}
/* Fixes the size on quick-entry form */
.select2-container--bootstrap {
display: table;
table-layout: fixed;
.ts-control {
> input {
min-width: unset;
}
.item {
white-space: nowrap!important;
}
}
.ts-dropdown {
.optgroup-header {
background-color: #eee;
color: #000;
font-weight: 600;
}
[data-selectable].option {
padding-left: 20px;
}
}

View File

@@ -1,19 +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.
*/
@media (min-width: $screen-sm-min) {
.sidebar-mini.sidebar-collapse .sidebar-menu li.header {
border-top-width: 1px;
border-top-style: solid;
display: block !important;
margin-top: 16px;
opacity: 0.4;
span {
display: none !important;
}
}
}

View File

@@ -1,69 +0,0 @@
$skins:
"blue" $yellow #fff $yellow $red,
"black" $yellow #000 $green $red,
"green" $yellow #fff $yellow $red,
"purple" $yellow #fff $yellow $red,
"red" $yellow #fff $yellow $yellow,
"yellow" $blue #fff $green $red
;
@each $skinColor, $navHighlight, $tickTacColor, $startButton, $stopButton in $skins {
@each $prefix in (".skin-#{$skinColor}", ".skin-#{$skinColor}-light") {
@media (max-width: $screen-sm-max) {
#{$prefix} {
.main-header .navbar .dropdown-menu li.divider {
background-color: #ccc;
}
.main-header .navbar .dropdown-menu li a {
color: #444;
}
.main-header .navbar .dropdown-menu li a:hover {
background: #fff;
}
}
}
#{$prefix} {
.navbar-nav {
.user-menu {
ul {
li {
a {
&:hover {
i {
color: $navHighlight;
}
}
}
}
}
}
.notifications-menu > .dropdown-menu > li .menu > li > a:hover i {
color: $navHighlight;
}
}
.ticktac-single {
i {
color: $tickTacColor;
}
span {
color: $tickTacColor;
}
&:hover {
.ticktac-stop {
i {
color: $stopButton;
}
}
.ticktac-start {
i {
color: $startButton;
}
}
}
}
}
}
}

View File

@@ -0,0 +1,6 @@
@import '~@tabler/core/src/scss/variables';
/* fix for vertical labels in date-time pickers, if they are used in half-column forms, e.g. "create tasks" or "create expenses" */
fieldset legend.col-form-label {
padding-top: 0;
}

View File

@@ -4,39 +4,82 @@
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
@import "variables";
table.dataTable.table > tbody > tr > td {
vertical-align: middle;
.img-circle {
width: 30px;
height: 30px;
margin-right: 0;
&.teamlead {
box-shadow: 0 0 5px 3px $gray-lte;
}
table.dataTable {
clear: both;
margin-bottom: 0 !important;
max-width: none !important;
thead .sorting,
thead .sorting_asc,
thead .sorting_desc {
cursor: pointer;
position: relative;
}
thead .sorting_asc,
thead .sorting_desc {
font-weight: bold;
}
thead .sorting:after,
thead .sorting_asc:after,
thead .sorting_desc:after {
padding-left: 5px;
font-family: 'Font Awesome 5 Free';
opacity: 0.5;
font-size: 12px;
}
thead .sorting:after {
opacity: 0.2;
content: "\f0dc"; /* sort */
}
thead .sorting_asc:after {
content: "\f077";
}
thead .sorting_desc:after {
content: "\f078";
}
thead > tr > th {
vertical-align: top;
white-space: nowrap;
}
th:active {
outline: none;
}
}
span.label-color,
span.label-meta,
span.label-tag,
span.label-user,
span.label-activity,
span.label-project,
span.label-customer {
display: inline-block;
.name {
white-space: nowrap;
.name-inner {
white-space: normal;
}
table.table-bordered {
thead th,
thead td {
border-left-width: 0;
border-top-width: 0;
}
.dot {
font-size: 9px;
margin-right: 3px;
color: $gray-lte;
tbody th,
tbody td {
border-left-width: 0;
border-bottom-width: 0;
}
tfoot th,
tfoot td {
border-left-width: 0;
border-bottom-width: 0;
}
th:last-child,
td:last-child {
border-right-width: 0;
}
}
@include media-breakpoint-up(xl) {
table.dataTable.table > tbody > tr > td {
vertical-align: middle;
}
}
@@ -46,17 +89,17 @@ table.dataTable {
width: 40px;
}
tr {
/* summary row - provided in the users own timesheet table */
/* summary row - used in many tables to show rows with summarized values like durations or money */
&.summary td {
font-weight: bold;
border-bottom: 1px solid #ccc;
background-color: #eaeaea;
border: 0;
background-color: var(--tblr-table-striped-bg);
}
th.multiCheckbox {
width: 15px;
}
&.overlapping {
border-top: 2px solid rgba(255, 0, 0, 0.1);
border-top: 2px solid rgba(214,57,57,.1);
}
&.exported {
opacity: 0.7;
@@ -91,15 +134,6 @@ table.dataTable {
opacity: 0.8;
}
}
tfoot {
tr {
/* overwritten if used in tfoot, to switch the border from below to top */
&.summary td {
border-top: 1px solid #ccc;
border-bottom: 0;
}
}
}
th.weekend,
td.weekend {
background-color: #f9f9f9;

View File

@@ -1,3 +0,0 @@
.tab-content {
margin-top: 20px;
}

View File

@@ -0,0 +1,56 @@
@include dark-mode {
.form-check-input {
border-color: $input-border-color;
}
/* selectpicker.scss / Tom Select */
.ts-wrapper.form-select .ts-control,
.ts-wrapper.form-select.single.input-active .ts-control {
color: $dark-mode-text;
}
.ts-dropdown {
background-color: $dark-mode-lighten;
color: $dark-mode-text;
}
.ts-control {
> input {
min-width: unset;
color: $dark-mode-text;
}
.item {
background-color: $dark;
color: $dark-mode-text;
}
}
.optgroup-header {
background-color: $dropdown-dark-bg;
color: $dark-mode-text;
}
table.dataTable {
th.weekend, td.weekend {
background-color: $dark-mode-weekend;
color: $dark-mode-text;
}
/* must be defined after weekend, to overwrite it */
th.today, td.today {
background-color: $dark-mode-today;
color: $dark-mode-text;
}
tr {
&.summary td {
background-color: $dark-mode-darken;
}
}
}
.overlay {
background: rgba(0, 0, 0, 0.5);
> .fas {
color: $dark-mode-text;
}
}
}

43
assets/sass/ticktac.scss Normal file
View File

@@ -0,0 +1,43 @@
/*
* 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.
*/
@keyframes ticktac-blink {
0% { opacity: 1; }
5% { opacity: 0.95; }
10% { opacity: 0.9; }
15% { opacity: 0.85; }
20% { opacity: 0.8; }
25% { opacity: 0.75; }
30% { opacity: 0.7; }
35% { opacity: 0.65; }
40% { opacity: 0.6; }
45% { opacity: 0.65; }
50% { opacity: 0.7; }
55% { opacity: 0.75; }
60% { opacity: 0.8; }
65% { opacity: 0.85; }
70% { opacity: 0.9; }
75% { opacity: 0.95; }
100% { opacity: 1; }
}
.ticktac-start,
.ticktac-stop {
i {
animation: ticktac-blink 2s step-end infinite;
}
}
/* Full size with bright colors => navbar turns dark in small screens: button and color would still be white */
@include media-breakpoint-down(lg) {
aside.navbar .btn-outline-white,
header.navbar .btn-outline-white {
background-color: var(--tblr-body-color);
border-color: hsla(0,0%,100%,.2);
color: hsla(0,0%,100%,.7);
}
}

View File

@@ -1,26 +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.
*/
@media (min-width: $screen-sm-min) {
.toolbar {
// @deprecated since 1.2 - using the navbar-form is deprecated and will be removed with 2.0
form.navbar-form {
font-size: $font-size-base;
.form-control {
display: inline-block;
width: 100%;
vertical-align: middle;
}
.control-label {
width: 100%;
}
.input-group {
width: 100%;
}
}
}
}

View File

@@ -5,129 +5,26 @@
* file that was distributed with this source code.
*/
@import "~bootstrap-sass/assets/stylesheets/bootstrap/variables";
@import "~bootstrap/scss/functions";
@import "~bootstrap/scss/variables";
@import "~bootstrap/scss/mixins";
@import '~@tabler/core/src/scss/variables';
@import '~@tabler/core/src/scss/mixins';
/* For highlighting rows, fieldsets and so on */
$highlight-bg: #f5f5f5;
$highlight-today: #fcf8e3;
/**
* BELOW: A simple one to one clone of /node_modules/admin-lte/build/less/variables.less
*/
// COLORS
// --------------------------------------------------------
// Primary
//$light-blue: #3c8dbc;
// Danger
$red: #dd4b39;
// Success
$green: #00a65a;
// Info
//$aqua: #00c0ef;
// Warning
$yellow: #f0ad4e;
$blue: #0073b7;
//$navy: #001F3F;
//$teal: #39CCCC;
//$olive: #3D9970;
//$lime: #01FF70;
//$orange: #FF851B;
//$fuchsia: #F012BE;
//$purple: #605ca8;
//$maroon: #D81B60;
//$black: #111;
$gray-lte: #d2d6de;
$kimai-default: $gray-lte;
// LAYOUT
// --------------------------------------------------------
//
// Side bar and logo width
//$sidebar-width: 230px;
//
// Link colors (Aka: <a> tags)
//$link-color: $light-blue;
//$link-hover-color: lighten($link-color, 15%);
//
// Body background (Affects main content background only)
//$body-bg: #ecf0f5; // DEPRECATED
//$content-bg: #ecf0f5;
//
// SIDEBAR SKINS
// --------------------------------------------------------
//
// Dark sidebar
//$sidebar-dark-bg: #222d32;
//$sidebar-dark-hover-bg: darken($sidebar-dark-bg, 2%);
//$sidebar-dark-color: lighten($sidebar-dark-bg, 60%);
//$sidebar-dark-hover-color: #fff;
//$sidebar-dark-submenu-bg: lighten($sidebar-dark-bg, 5%);
//$sidebar-dark-submenu-color: lighten($sidebar-dark-submenu-bg, 40%);
//$sidebar-dark-submenu-hover-color: #fff;
//
// Light sidebar
//$sidebar-ligh//t-bg: #f9fafc;
//$sidebar-light-hover-bg: lighten(#f0f0f1, 1.5%);
//$sidebar-light-color: #444;
//$sidebar-light-hover-color: #000;
//$sidebar-light-submenu-bg: $sidebar-light-hover-bg;
//$sidebar-light-submenu-color: #777;
//$sidebar-light-submenu-hover-color: #000;
//
// sidebar-expanded-on-hover
//$sidebar-expanded-shadow: 3px 0 8px rgba(0,0,0,.125);
//
// CONTROL SIDEBAR
// --------------------------------------------------------
//$control-sidebar-width: $sidebar-width;
//
// BOXES
// --------------------------------------------------------
//$box-border-color: #f4f4f4;
$box-border-radius: 3px;
//$box-footer-bg: #fff;
//$box-boxshadow: 0 1px 1px rgba(0, 0, 0, .1);
//$box-padding: 10px;
//
// Box variants
//$box-default-border-top-color: #d2d6de;
//
// BUTTONS
// --------------------------------------------------------
//$btn-boxshadow: none;
//
// PROGRESS BARS
// --------------------------------------------------------
//$progress-bar-border-radius: 1px;
//$progress-bar-sm-border-radius: 1px;
//$progress-bar-xs-border-radius: 1px;
//
// FORMS
// --------------------------------------------------------
//$input-radius: 0;
//
// BUTTONS
// --------------------------------------------------------
//
// Border radius for non flat buttons
//$btn-border-radius: 3px;
//
// DIRECT CHAT
// --------------------------------------------------------
//$direct-chat-height: 250px;
//$direct-chat-default-msg-bg: $gray-lte;
//$direct-chat-default-font-color: #444;
//$direct-chat-default-msg-border-color: $gray-lte;
//
// CHAT WIDGET
// --------------------------------------------------------
//$attachment-border-radius: 3px;
//
// TRANSITIONS SETTINGS
// --------------------------------------------------------
//
// Transition global options
//$transition-speed: .3s;
//$transition-fn: ease-in-out;
//
$dark-mode-today: var(--tblr-blue);
$dark-mode-weekend: lighten($dark, 5%) !default;