Files
kimai/assets/js/forms/KimaiTimesheetForm.js
2025-08-08 23:25:42 +02:00

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