699 lines
22 KiB
JavaScript
699 lines
22 KiB
JavaScript
/*
|
|
* This file is part of the Kimai time-tracking app.
|
|
*
|
|
* For the full copyright and license information, please view the LICENSE
|
|
* file that was distributed with this source code.
|
|
*/
|
|
|
|
/*!
|
|
* [KIMAI] 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._beginListener;
|
|
this._beginTime.removeEventListener('blur', this._beginBlurListener);
|
|
delete this._beginBlurListener;
|
|
delete this._beginTime;
|
|
}
|
|
|
|
if (this._endTime !== undefined) {
|
|
this._endTime.removeEventListener('change', this._endListener);
|
|
delete this._endListener;
|
|
this._endTime.removeEventListener('blur', this._endBlurListener);
|
|
delete this._endBlurListener;
|
|
delete this._endTime;
|
|
}
|
|
|
|
if (this._duration !== undefined) {
|
|
this._duration.removeEventListener('change', this._durationListener);
|
|
delete this._durationListener;
|
|
this._duration.removeEventListener('keydown', this._durationKeyListener);
|
|
delete this._durationKeyListener;
|
|
this._duration.removeEventListener('blur', this._durationBlurListener);
|
|
delete this._durationBlurListener;
|
|
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._beginBlurListener = () => this._parseBeginTime();
|
|
this._endListener = () => this._changedEnd();
|
|
this._endBlurListener = () => this._parseEndTime();
|
|
this._durationListener = () => this._changedDuration();
|
|
this._durationKeyListener = (event) => this._changeDurationOnKeypress(event);
|
|
this._durationBlurListener = () => this._parseDuration();
|
|
|
|
this._beginDate.addEventListener('change', this._beginListener);
|
|
this._beginTime.addEventListener('change', this._beginListener);
|
|
this._beginTime.addEventListener('blur', this._beginBlurListener);
|
|
this._endTime.addEventListener('change', this._endListener);
|
|
this._endTime.addEventListener('blur', this._endBlurListener);
|
|
this._duration.addEventListener('change', this._durationListener);
|
|
this._duration.addEventListener('keydown', this._durationKeyListener);
|
|
this._duration.addEventListener('blur', this._durationBlurListener);
|
|
|
|
if (this._duration !== null && this._durationToggle !== null) {
|
|
this._durationToggleListener = () => {
|
|
this._durationToggle.classList.toggle('text-success');
|
|
};
|
|
this._durationToggle.addEventListener('click', this._durationToggleListener);
|
|
}
|
|
}
|
|
|
|
_parseBeginTime()
|
|
{
|
|
if (this._beginTime.value === '') {
|
|
return;
|
|
}
|
|
|
|
let newBeginTime = this._formatTimeForParsing(this._beginTime.value, this._beginTime.dataset['format']);
|
|
if (newBeginTime !== this._beginTime.value) {
|
|
this._beginTime.value = newBeginTime;
|
|
this._changedBegin();
|
|
}
|
|
}
|
|
|
|
_parseEndTime()
|
|
{
|
|
if (this._endTime.value === '') {
|
|
return;
|
|
}
|
|
|
|
let newEndTime = this._formatTimeForParsing(this._endTime.value, this._endTime.dataset['format']);
|
|
if (newEndTime !== this._endTime.value) {
|
|
this._endTime.value = newEndTime;
|
|
this._changedEnd();
|
|
}
|
|
}
|
|
|
|
_parseDuration()
|
|
{
|
|
if (this._duration.value === '') {
|
|
return;
|
|
}
|
|
|
|
this._setDurationAsString(this._getParsedDuration());
|
|
}
|
|
|
|
/**
|
|
* Receives a time, written by a human, probably in an invalid format.
|
|
* This method supports 12-hour or 24-hour format, the format string contains an uppercase "A" in case of the 12-hour format.
|
|
* If it is 12-hour format, then always en-US locallized with AM/PM.
|
|
*
|
|
* Ruleset:
|
|
* - Some locales use a dot instead of a colon, always replace the dot in HH.mm with a colon as in HH:mm
|
|
* - If there is an "am" or "pm", always uppercase them
|
|
* - Split the string into time and prefix: if AM/PM is included remove it and remember for later
|
|
* - If the time is a 1 or 2 character long number: use as hours
|
|
* - If the time now is 3 character long: use the 1 char as hour and the 2 and 3 char as minute
|
|
* - If the time now is 4 character long: use the 1 and 2 char as hour and the 3 and 4 char as minutes
|
|
* - If the format is 12-hour: try to identify the correct time and suffix
|
|
* - If the format is 12-hour and misses the AM/PM: try to detect whether it
|
|
* - If the time contains AM or PM, make sure that it is always prefixed by a space character
|
|
*
|
|
* @param {string} time
|
|
* @param {string} format
|
|
* @returns {string}
|
|
* @private
|
|
*/
|
|
_formatTimeForParsing(time, format)
|
|
{
|
|
let formatted = time.trim();
|
|
|
|
// replace invalid separators with colon
|
|
formatted = formatted.replace(/\.|;|,/g, ':');
|
|
// uppercase 12-hour format
|
|
formatted = formatted.replace(/am/i, 'AM');
|
|
formatted = formatted.replace(/pm/i, 'PM');
|
|
|
|
// Split time and AM/PM suffix if present
|
|
let suffix = '';
|
|
let hour = 0;
|
|
let minute = 0;
|
|
|
|
let timePart = formatted;
|
|
const ampmMatch = formatted.match(/\s*(AM|PM)$/i);
|
|
if (ampmMatch) {
|
|
suffix = ampmMatch[1].toUpperCase();
|
|
timePart = formatted.replace(/\s*(AM|PM)$/i, '').trim();
|
|
}
|
|
|
|
if (timePart.indexOf(':') !== -1) {
|
|
const match = timePart.match(/(?:(\d+):)?(\d+)/);
|
|
hour = parseInt(match?.[1] || 0, 10);
|
|
minute = parseInt(match?.[2] || 0, 10);
|
|
} else {
|
|
timePart = timePart.replace(/:/, '');
|
|
|
|
if (/^\d{1,2}$/.test(timePart)) {
|
|
hour = timePart;
|
|
}
|
|
|
|
if (/^\d{3}$/.test(timePart)) {
|
|
hour = timePart.slice(0, 1);
|
|
minute = timePart.slice(1);
|
|
}
|
|
|
|
if (/^\d{4}$/.test(timePart)) {
|
|
hour = timePart.slice(0, 2);
|
|
minute = timePart.slice(2);
|
|
}
|
|
}
|
|
|
|
hour = parseInt(hour);
|
|
minute = parseInt(minute);
|
|
|
|
// just in case a person entered a wrong time like 35 hours
|
|
hour = hour % 24;
|
|
minute = minute % 60;
|
|
|
|
// format is 12-hour
|
|
if (format.toUpperCase().indexOf('A') !== -1) {
|
|
// time entered in 24-hour: convert to 12-hour format
|
|
if (hour > 12 && hour < 24) {
|
|
hour = hour - 12;
|
|
suffix = 'PM';
|
|
}
|
|
|
|
// if the person forgot to add a suffix, calculate it and convert time
|
|
if (suffix === '') {
|
|
if (hour === 0) {
|
|
hour = 12;
|
|
suffix = 'AM';
|
|
} else if (hour === 12) {
|
|
suffix = 'PM';
|
|
} else {
|
|
suffix = 'AM';
|
|
}
|
|
}
|
|
|
|
if (suffix === 'PM' && hour === 0) {
|
|
hour = 12;
|
|
}
|
|
|
|
} else {
|
|
// this is the 34-hour format branch
|
|
|
|
// check if the person entered time in 12-hour format and convert it
|
|
if (suffix === 'AM' && hour === 12) {
|
|
hour = 0;
|
|
} else if (suffix === 'PM' && hour !== 12) {
|
|
hour = (hour + 12) % 24;
|
|
}
|
|
|
|
// make sure we have no suffix
|
|
suffix = '';
|
|
}
|
|
|
|
formatted = hour + ':' + minute.toString().padStart(2, '0');
|
|
if (suffix !== '') {
|
|
formatted = formatted + ' ' + suffix.trim();
|
|
}
|
|
|
|
return formatted;
|
|
}
|
|
|
|
_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;
|
|
}
|
|
|
|
let date = this._parseBegin(this._beginTime.dataset['format']);
|
|
|
|
if (date.invalid) {
|
|
date = this._parseBegin(this._fixTimeFormat(this._beginTime.dataset['format']));
|
|
|
|
if (date.invalid) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return date;
|
|
}
|
|
|
|
_parseBegin(timeFormat)
|
|
{
|
|
return this.getDateUtils().fromFormat(
|
|
this._beginDate.value + ' ' + this._beginTime.value,
|
|
this._beginDate.dataset['format'] + ' ' + timeFormat,
|
|
);
|
|
}
|
|
|
|
_parseEnd(endDate, timeFormat)
|
|
{
|
|
let date = this.getDateUtils().fromFormat(
|
|
endDate.toFormat('yyyy-LL-dd') + ' ' + this._endTime.value,
|
|
'yyyy-LL-dd ' + timeFormat,
|
|
);
|
|
|
|
if (date.invalid) {
|
|
date = this.getDateUtils().fromFormat(
|
|
endDate.toFormat('yyyy-LL-dd') + ' ' + this._endTime.value,
|
|
'yyyy-LL-dd ' + this._fixTimeFormat(timeFormat),
|
|
);
|
|
}
|
|
|
|
return date;
|
|
}
|
|
|
|
_fixTimeFormat(format)
|
|
{
|
|
return format.replace('HH', 'H').replace('hh', 'h');
|
|
}
|
|
|
|
/**
|
|
* @returns {DateTime|null}
|
|
* @private
|
|
*/
|
|
_getEnd()
|
|
{
|
|
if (this._endTime.value === '') {
|
|
return null;
|
|
}
|
|
|
|
let date = this._parseEnd(DateTime.now(), this._endTime.dataset['format']);
|
|
|
|
const begin = this._getBegin();
|
|
if (begin !== null) {
|
|
date = this._parseEnd(begin, 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 or empty 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() || this._duration.value === '') {
|
|
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._addSecondsToEndDate(newBegin, seconds);
|
|
} else if (begin === null && end !== null) {
|
|
this._applyDateToField(end.minus({seconds: seconds}), this._beginDate, this._beginTime);
|
|
} else if (begin !== null && seconds >= 0) {
|
|
this._addSecondsToEndDate(begin, seconds);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
/**
|
|
* @param {DateTime} dateTime
|
|
* @param {int} seconds
|
|
* @private
|
|
*/
|
|
_addSecondsToEndDate(dateTime, seconds)
|
|
{
|
|
// if the duration is longer than one day, the end field should be empty
|
|
// so kimai can calculate it after submitting the data from start + duration
|
|
if (seconds < 86400) {
|
|
this._applyDateToField(dateTime.plus({seconds: seconds}), null, this._endTime);
|
|
} else {
|
|
this._endTime.value = '';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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);
|
|
}
|
|
|
|
/**
|
|
* @param {KeyboardEvent} event
|
|
* @private
|
|
*/
|
|
_changeDurationOnKeypress(event)
|
|
{
|
|
switch (event.key) {
|
|
case 'ArrowUp':
|
|
case 'ArrowDown':
|
|
case 'PageUp':
|
|
case 'PageDown':
|
|
case 'Home':
|
|
case 'End':
|
|
this._setDurationAsString(this._getParsedDuration());
|
|
break;
|
|
default:
|
|
return; // Ignore other keys
|
|
}
|
|
|
|
this._changeTimeOnKeypress(event, this._duration, 99999, this._durationListener);
|
|
}
|
|
|
|
/**
|
|
* This method helps the user to change a duration field with simple keyboard interaction:
|
|
* - Read the current duration from the given timeField input in format HH:MM (no seconds)
|
|
* - Change the duration based on the rules below
|
|
* - Write the new duration back to the field
|
|
* - If the field is empty or invalid it uses 00:00 as start-time
|
|
* - Duration cannot exceed maxtime (which is given in minutes)
|
|
* - Duration cannot drop below 00:00
|
|
* - Read the position of the cursor and decide whether to increase minutes or hours: if the cursor is in the hour section (before the colon) change hours, if the cursor is in the minute section (after the colon) change minutes
|
|
* - It reads the pressed key from the given KeyboardEvent and changes the duration accordingly to the rules below
|
|
*
|
|
* Rules to apply when a key is pressed:
|
|
* - ArrowUp key to increase the duration (either 5 minutes or 1 hour, depending on the cursor position)
|
|
* - ArrowDown key to decrease the duration (either 5 minutes or 1 hour, depending on the cursor position)
|
|
* - PageUp key to increase the duration by 1 hour
|
|
* - PageDown key to decrease the duration by 1 hour
|
|
* - Home key to set the duration to 08:00
|
|
* - End key to set the duration to 00:00
|
|
* - all other keys are ignored
|
|
*
|
|
* @param {KeyboardEvent} event
|
|
* @param {HTMLElement} timeField
|
|
* @param {int} maxTime
|
|
* @param {function} changeCallback
|
|
* @private
|
|
*/
|
|
_changeTimeOnKeypress(event, timeField, maxTime, changeCallback)
|
|
{
|
|
// Parse current value or default to 00:00
|
|
let value = timeField.value || '00:00';
|
|
let [hours, minutes] = value.split(':').map(Number);
|
|
if (isNaN(hours)) { hours = 0; }
|
|
if (isNaN(minutes)) { minutes = 0; }
|
|
|
|
// Cursor position: before or after colon
|
|
const cursorPos = timeField.selectionStart || 0;
|
|
const colonPos = value.indexOf(':');
|
|
const inHour = cursorPos <= colonPos;
|
|
|
|
// Helper to clamp values
|
|
const clamp = (h, m) => {
|
|
let total = h * 60 + m;
|
|
if (total < 0) { total = 0; }
|
|
if (total > maxTime) { total = maxTime; }
|
|
h = Math.floor(total / 60);
|
|
m = total % 60;
|
|
return [h, m];
|
|
};
|
|
|
|
switch (event.key) {
|
|
case 'ArrowUp':
|
|
if (inHour) {
|
|
[hours, minutes] = clamp(hours + 1, minutes);
|
|
} else {
|
|
[hours, minutes] = clamp(hours, minutes + 5);
|
|
}
|
|
break;
|
|
case 'ArrowDown':
|
|
if (inHour) {
|
|
[hours, minutes] = clamp(hours - 1, minutes);
|
|
} else {
|
|
[hours, minutes] = clamp(hours, minutes - 5);
|
|
}
|
|
break;
|
|
case 'PageUp':
|
|
[hours, minutes] = clamp(hours + 1, minutes);
|
|
event.preventDefault();
|
|
break;
|
|
case 'PageDown':
|
|
[hours, minutes] = clamp(hours - 1, minutes);
|
|
event.preventDefault();
|
|
break;
|
|
case 'Home':
|
|
// TODO this should use the configured working time for today
|
|
hours = 8;
|
|
minutes = 0;
|
|
event.preventDefault();
|
|
break;
|
|
case 'End':
|
|
hours = 0;
|
|
minutes = 0;
|
|
event.preventDefault();
|
|
break;
|
|
default:
|
|
return; // Ignore other keys
|
|
}
|
|
|
|
// Format and set value
|
|
timeField.value = `${hours}:${minutes.toString().padStart(2, '0')}`;
|
|
// trigger update of linked fields
|
|
changeCallback(timeField);
|
|
// Move cursor to original position if possible
|
|
setTimeout(() => {
|
|
timeField.setSelectionRange(cursorPos, cursorPos);
|
|
}, 0);
|
|
}
|
|
}
|