diff --git a/frontend/src/stimulus/controllers/dynamic/time-entry.controller.ts b/frontend/src/stimulus/controllers/dynamic/time-entry.controller.ts index b3e3ba4893c3..ae9d47b16623 100644 --- a/frontend/src/stimulus/controllers/dynamic/time-entry.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/time-entry.controller.ts @@ -29,14 +29,19 @@ */ import { Controller } from '@hotwired/stimulus'; +import { parseChronicDuration, outputChronicDuration } from 'core-app/shared/helpers/chronic_duration'; import flatpickr from 'flatpickr'; +interface HTMLInputElementWithFlatpickr extends HTMLInputElement { + _flatpickr?:flatpickr.Instance; +} + export default class TimeEntryController extends Controller { static targets = ['startTimeInput', 'endTimeInput', 'hoursInput']; - declare readonly startTimeInputTarget: HTMLInputElement; - declare readonly endTimeInputTarget: HTMLInputElement; - declare readonly hoursInputTarget: HTMLInputElement; + declare readonly startTimeInputTarget:HTMLInputElementWithFlatpickr; + declare readonly endTimeInputTarget:HTMLInputElementWithFlatpickr; + declare readonly hoursInputTarget:HTMLInputElement; startTimeInputTargetConnected() { this.initTimePicker(this.startTimeInputTarget); @@ -46,33 +51,62 @@ export default class TimeEntryController extends Controller { this.initTimePicker(this.endTimeInputTarget); } - dateChanged() { + datesChanged(initiatedBy:HTMLInputElement) { const startTimeParts = this.startTimeInputTarget.value.split(':'); const endTimeParts = this.endTimeInputTarget.value.split(':'); - const startTime = parseInt(startTimeParts[0], 10) * 60 + parseInt(startTimeParts[1], 10); - const endTime = parseInt(endTimeParts[0], 10) * 60 + parseInt(endTimeParts[1], 10); + const startTimeInMinutes = parseInt(startTimeParts[0], 10) * 60 + parseInt(startTimeParts[1], 10); + const endTimeInMinutes = parseInt(endTimeParts[0], 10) * 60 + parseInt(endTimeParts[1], 10); + const hoursInMinutes = Math.round((parseChronicDuration(this.hoursInputTarget.value) || 0) / 60); + + // We calculate the hours field if: + // - We have start & end time and no hours + // - We have start & end time and we have triggered the change from the end time field + if (startTimeInMinutes && endTimeInMinutes && (hoursInMinutes === 0 || initiatedBy === this.endTimeInputTarget)) { + const duration = endTimeInMinutes - startTimeInMinutes; + this.hoursInputTarget.value = outputChronicDuration(duration * 60, { format: 'hours_only' }) || ''; + } else if (startTimeInMinutes && hoursInMinutes) { + const newEndTime = startTimeInMinutes + hoursInMinutes; - this.toggleEndPlusOneDayCapion(endTime < startTime); + const targetDate = new Date(); + targetDate.setHours(Math.floor(newEndTime / 60)); + targetDate.setMinutes(Math.round(newEndTime % 60)); + targetDate.setSeconds(0); + this.endTimeInputTarget._flatpickr!.setDate(targetDate); // eslint-disable-line no-underscore-dangle + } + + this.toggleEndTimePlusCaption(startTimeInMinutes + hoursInMinutes); + } + + hoursChanged() { + // Parse input through our chronic duration parser and then reformat as hours that can be nicely parsed on the + // backend + const hours = parseChronicDuration(this.hoursInputTarget.value, { defaultUnit: 'hours', ignoreSecondsWhenColonSeperated: true }); + this.hoursInputTarget.value = outputChronicDuration(hours, { format: 'hours_only' }) || ''; + + this.datesChanged(this.hoursInputTarget); + } + + hoursKeyEnterPress(event:KeyboardEvent) { + if (event.currentTarget instanceof HTMLInputElement) { + event.currentTarget.blur(); + } } - toggleEndPlusOneDayCapion(show: boolean) { + toggleEndTimePlusCaption(endTimeInMinutes:number) { const formControl = this.endTimeInputTarget.closest('.FormControl') as HTMLElement; + formControl.querySelectorAll('.FormControl-caption').forEach((caption) => caption.remove()); - if (show) { + if (endTimeInMinutes > (24 * 60)) { + const diffInDays = Math.floor(endTimeInMinutes / (60 * 24)); const span = document.createElement('span'); span.className = 'FormControl-caption'; - span.innerText = '+1 day'; + span.innerText = `+ ${diffInDays} ${diffInDays === 1 ? 'day' : 'days'}`; formControl.append(span); - } else { - const caption = formControl.querySelector('.FormControl-caption'); - if (caption) { - caption.remove(); - } } } - initTimePicker(field: HTMLInputElement) { + initTimePicker(field:HTMLInputElement) { flatpickr(field, { enableTime: true, noCalendar: true, @@ -81,7 +115,7 @@ export default class TimeEntryController extends Controller { static: true, appendTo: document.querySelector('#time-entry-dialog') as HTMLElement, onChange: () => { - this.dateChanged(); + this.datesChanged(field); }, }); } diff --git a/modules/costs/app/components/time_entries/time_entry_form.rb b/modules/costs/app/components/time_entries/time_entry_form.rb index cdc14754e5b1..c692b4589672 100644 --- a/modules/costs/app/components/time_entries/time_entry_form.rb +++ b/modules/costs/app/components/time_entries/time_entry_form.rb @@ -27,10 +27,10 @@ class TimeEntryForm < ApplicationForm type: "date", required: true, datepicker_options: { inDialog: true }, + value: model.spent_on&.iso8601, label: TimeEntry.human_attribute_name(:spent_on) f.group(layout: :horizontal) do |g| - # TODO: Add a time picker based on the date picker linked above g.text_field name: :start_time, required: true, label: TimeEntry.human_attribute_name(:start_time), @@ -47,7 +47,8 @@ class TimeEntryForm < ApplicationForm f.text_field name: :hours, required: true, label: TimeEntry.human_attribute_name(:hours), - data: { "time-entry-target" => "hoursInput" } + data: { "time-entry-target" => "hoursInput", + "action" => "blur->time-entry#hoursChanged keypress.enter->time-entry#hoursKeyEnterPress" } f.work_package_autocompleter name: :work_package_id, label: TimeEntry.human_attribute_name(:work_package), diff --git a/modules/costs/app/models/time_entry.rb b/modules/costs/app/models/time_entry.rb index 32048cb152c8..9984922099c6 100644 --- a/modules/costs/app/models/time_entry.rb +++ b/modules/costs/app/models/time_entry.rb @@ -128,6 +128,7 @@ def costs_visible_by?(usr) def start_timestamp return nil if start_time.blank? return nil if time_zone.blank? + return nil if spent_on.blank? time_zone_object.local(spent_on.year, spent_on.month, spent_on.day, start_time / 60, start_time % 60) end @@ -136,6 +137,7 @@ def end_timestamp return nil if start_time.blank? return nil if time_zone.blank? return nil if hours.blank? + return nil if spent_on.blank? start_timestamp + hours.hours end