diff --git a/app/components/op_primer/border_box_table_component.html.erb b/app/components/op_primer/border_box_table_component.html.erb index 9efdd2d5ec6..2bba751fdde 100644 --- a/app/components/op_primer/border_box_table_component.html.erb +++ b/app/components/op_primer/border_box_table_component.html.erb @@ -30,6 +30,7 @@ See COPYRIGHT and LICENSE files for more details. <%= render( border_box_container( + id: container_id, classes: container_class, test_selector: ) @@ -55,10 +56,10 @@ See COPYRIGHT and LICENSE files for more details. if has_actions? concat render( Primer::BaseComponent.new( - classes: heading_class, - tag: :div + classes: header_classes(:actions), + tag: :span ) - ) + ) { action_row_header_content } end end diff --git a/app/components/op_primer/border_box_table_component.rb b/app/components/op_primer/border_box_table_component.rb index 1e116079096..f8d447a54b8 100644 --- a/app/components/op_primer/border_box_table_component.rb +++ b/app/components/op_primer/border_box_table_component.rb @@ -138,6 +138,10 @@ module OpPrimer nil end + def action_row_header_content + nil + end + def footer raise ArgumentError, "Need to provide footer content" end diff --git a/app/components/op_primer/border_box_table_component.sass b/app/components/op_primer/border_box_table_component.sass index 34902845700..8d20b309126 100644 --- a/app/components/op_primer/border_box_table_component.sass +++ b/app/components/op_primer/border_box_table_component.sass @@ -23,6 +23,11 @@ &:not(:last-child) padding-right: 6px + &:last-child + justify-content: flex-end !important + display: flex !important + flex-direction: row !important + &--mobile-heading, &--row-label display: none diff --git a/app/components/table_component.html.erb b/app/components/table_component.html.erb index 9449d5dc489..f3125367435 100644 --- a/app/components/table_component.html.erb +++ b/app/components/table_component.html.erb @@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -
+
diff --git a/app/components/table_component.rb b/app/components/table_component.rb index fd5d0ba28d0..369f8a9c69c 100644 --- a/app/components/table_component.rb +++ b/app/components/table_component.rb @@ -31,8 +31,8 @@ ## # Abstract view component. Subclass this for a concrete table. class TableComponent < ApplicationComponent - def initialize(rows: [], **options) - super(rows, **options) + def initialize(rows: [], **) + super(rows, **) end class << self @@ -107,7 +107,7 @@ class TableComponent < ApplicationComponent end def sortable_columns_correlation - sortable_columns.to_h { [_1.to_s, _1.to_s] } + sortable_columns.to_h { [it.to_s, it.to_s] } .with_indifferent_access end @@ -153,20 +153,22 @@ class TableComponent < ApplicationComponent model end - def row_class - self.class.row_class - end + delegate :row_class, to: :class def container_class nil end + def container_id + nil + end + def columns - self.class.columns + self.class.columns.reject { skip_column?(it) } end def sortable_columns - self.class.sortable_columns + self.class.sortable_columns.reject { skip_column?(it) } end def render_collection(rows) @@ -193,6 +195,10 @@ class TableComponent < ApplicationComponent true end + def skip_column?(_column) + false + end + def sortable_column?(column) sortable? && sortable_columns.include?(column.to_sym) end diff --git a/app/helpers/colors_helper.rb b/app/helpers/colors_helper.rb index 96af0e1c73a..ee613ef29bf 100644 --- a/app/helpers/colors_helper.rb +++ b/app/helpers/colors_helper.rb @@ -71,7 +71,7 @@ module ColorsHelper color = entry.color if color.nil? - concat ".#{hl_inline_class(name, entry)}::before { display: none }\n" + concat ".#{hl_inline_class(name, entry)}::before { display: none }" return end @@ -82,6 +82,13 @@ module ColorsHelper end set_background_colors_for(class_name: ".#{hl_background_class(name, entry)}", color:) + + # generic class for color + set_generic_color_for(class_name: ".#{hl_color_class(name, entry)}", color:) + end + + def hl_color_class(name, model) + "__hl_#{name}_#{model.id}" end def hl_inline_class(name, model) @@ -115,6 +122,13 @@ module ColorsHelper DesignColor.find_by(variable:)&.hexcode end + def set_generic_color_for(class_name:, color:) + mode = User.current.pref.theme.split("_", 2)[0] + mode_variables = mode == "dark" ? default_variables_dark : default_variables_light + + concat "#{class_name} { #{default_color_styles(color.hexcode)} #{mode_variables} }" + end + def set_background_colors_for(class_name:, color:) mode = User.current.pref.theme.split("_", 2)[0] @@ -141,66 +155,82 @@ module ColorsHelper end end - # rubocop:disable Layout/LineLength def default_color_styles(hex) color = ColorConversion::Color.new(hex) rgb = color.rgb hsl = color.hsl - "--color-r: #{rgb[:r]}; - --color-g: #{rgb[:g]}; - --color-b: #{rgb[:b]}; - --color-h: #{hsl[:h]}; - --color-s: #{hsl[:s]}; - --color-l: #{hsl[:l]}; - --perceived-lightness: calc( ((var(--color-r) * 0.2126) + (var(--color-g) * 0.7152) + (var(--color-b) * 0.0722)) / 255 ); - --lightness-switch: max(0, min(calc((1/(var(--lightness-threshold) - var(--perceived-lightness)))), 1));" + <<~CSS.squish + --color-r: #{rgb[:r]}; + --color-g: #{rgb[:g]}; + --color-b: #{rgb[:b]}; + --color-h: #{hsl[:h]}; + --color-s: #{hsl[:s]}; + --color-l: #{hsl[:l]}; + --perceived-lightness: calc( ((var(--color-r) * 0.2126) + (var(--color-g) * 0.7152) + (var(--color-b) * 0.0722)) / 255 ); + --lightness-switch: max(0, min(calc((1/(var(--lightness-threshold) - var(--perceived-lightness)))), 1)); + CSS end def default_variables_dark - "--lightness-threshold: 0.6; - --background-alpha: 0.18; - --lighten-by: calc(((var(--lightness-threshold) - var(--perceived-lightness)) * 100) * var(--lightness-switch));" + <<~CSS.squish + --lightness-threshold: 0.6; + --background-alpha: 0.18; + --lighten-by: calc(((var(--lightness-threshold) - var(--perceived-lightness)) * 100) * var(--lightness-switch)); + CSS end def default_variables_light - "--lightness-threshold: 0.453;" + <<~CSS.squish + --lightness-threshold: 0.453; + CSS end def highlighted_background_dark - "color: hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%)) !important; - background: rgba(var(--color-r), var(--color-g), var(--color-b), var(--background-alpha)) !important; - border: 1px solid hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%)) !important;" + <<~CSS.squish + color: hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%)) !important; + background: rgba(var(--color-r), var(--color-g), var(--color-b), var(--background-alpha)) !important; + border: 1px solid hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%)) !important; + CSS end def highlighted_background_light - style = "color: hsl(0deg, 0%, calc(var(--lightness-switch) * 100%)) !important; - background: rgb(var(--color-r), var(--color-g), var(--color-b)) !important;" + style = <<~CSS.squish + color: hsl(0deg, 0%, calc(var(--lightness-switch) * 100%)) !important; + background: rgb(var(--color-r), var(--color-g), var(--color-b)) !important; + CSS mode = User.current.pref.theme - style += - if mode == "light_high_contrast" - "border: 1px solid hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 75) * 1%), 1) !important;" - else - "border: 1px solid hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 15) * 1%)) !important;" - end + style += if mode == "light_high_contrast" + <<~CSS.squish + border: 1px solid hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 75) * 1%), 1) !important; + CSS + else + <<~CSS.squish + border: 1px solid hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 15) * 1%)) !important; + CSS + end style end def highlighted_foreground_dark - "color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%), 1) !important;" + <<~CSS.squish + color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) + var(--lighten-by)) * 1%), 1) !important; + CSS end def highlighted_foreground_light mode = User.current.pref.theme if mode == "light_high_contrast" - "color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.5)) * 1%), 1) !important;" + <<~CSS.squish + color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.5)) * 1%), 1) !important; + CSS else - "color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.22)) * 1%), 1) !important;" + <<~CSS.squish + color: hsla(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - (var(--color-l) * 0.22)) * 1%), 1) !important; + CSS end end - - # rubocop:enable Layout/LineLength end diff --git a/app/views/highlighting/styles.css.erb b/app/views/highlighting/styles.css.erb index 2582f25c686..cfd9dc5e4b3 100644 --- a/app/views/highlighting/styles.css.erb +++ b/app/views/highlighting/styles.css.erb @@ -11,6 +11,9 @@ <%= color_css %> +<%# usage classes %> +.__hl_border_top { border-top: 4px solid hsl(var(--color-h), calc(var(--color-s) * 1%), calc((var(--color-l) - 15) * 1%)) !important; } } + <%# Overdue tasks %> .__hl_date_due_today { color: #F76707 !important; } .__hl_date_overdue { color: #C92A2A !important; font-weight: var(--base-text-weight-bold); } diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index c0c41966505..18519a9a37f 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -135,7 +135,8 @@ Redmine::MenuManager.map :global_menu do |menu| menu.push :my_page, { controller: "/my/page", action: "show" }, after: :home, - icon: "person" + icon: "person", + caption: I18n.t("my_page.label") # Projects menu.push :projects, diff --git a/frontend/src/app/core/path-helper/path-helper.service.ts b/frontend/src/app/core/path-helper/path-helper.service.ts index 447be9d3ecc..f09e48fac92 100644 --- a/frontend/src/app/core/path-helper/path-helper.service.ts +++ b/frontend/src/app/core/path-helper/path-helper.service.ts @@ -369,4 +369,12 @@ export class PathHelperService { public timeEntryProjectDialog(projectId:string) { return `${this.projectPath(projectId)}/time_entries/dialog`; } + + public timeEntryUpdate(timeEntryId:string) { + return `${this.staticBase}/time_entries/${timeEntryId}`; + } + + public myTimeTrackingRefresh(date:string, viewMode:string, mode:string) { + return `${this.staticBase}/my/time-tracking/refresh?date=${date}&view_mode=${viewMode}&mode=${mode}`; + } } diff --git a/frontend/src/stimulus/controllers/dynamic/generic-dialog-close.controller.ts b/frontend/src/stimulus/controllers/dynamic/generic-dialog-close.controller.ts new file mode 100644 index 00000000000..f8c9e08edb8 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/generic-dialog-close.controller.ts @@ -0,0 +1,51 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class GenericDialogCloseController extends Controller { + // TODO: Add something that we can only refresh a certain turbo frame, etc + connect():void { + // handle dialog close event + document.addEventListener('dialog:close', this.dialogCloseListener); + } + + disconnect():void { + document.removeEventListener('dialog:close', this.dialogCloseListener); + } + + dialogCloseListener(this:void, event:CustomEvent):void { + const { detail: { dialog, submitted } } = event as { detail:{ dialog:HTMLDialogElement; submitted:boolean } }; + + if (dialog.id === 'time-entry-dialog' && submitted) { + window.location.reload(); + } + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/my/time-tracking.controller.ts b/frontend/src/stimulus/controllers/dynamic/my/time-tracking.controller.ts new file mode 100644 index 00000000000..eee431cf0aa --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/my/time-tracking.controller.ts @@ -0,0 +1,418 @@ +import { ActionEvent, Controller } from '@hotwired/stimulus'; +import { Calendar } from '@fullcalendar/core'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin from '@fullcalendar/interaction'; +import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import moment from 'moment'; +import allLocales from '@fullcalendar/core/locales-all'; +import { renderStreamMessage } from '@hotwired/turbo'; + +export default class MyTimeTrackingController extends Controller { + private turboRequests:TurboRequestsService; + private pathHelper:PathHelperService; + + static targets = ['calendar']; + + static values = { + mode: String, + viewMode: String, + timeEntries: Array, + initialDate: String, + canCreate: Boolean, + locale: String, + canEdit: Boolean, + allowTimes: Boolean, + forceTimes: Boolean, + }; + + declare readonly calendarTarget:HTMLElement; + declare readonly hasCalendarTarget:boolean; + declare readonly modeValue:string; + declare readonly timeEntriesValue:object[]; + declare readonly initialDateValue:string; + declare readonly canCreateValue:boolean; + declare readonly canEditValue:boolean; + declare readonly allowTimesValue:boolean; + declare readonly forceTimesValue:boolean; + declare readonly localeValue:string; + declare readonly viewModeValue:string; + + private calendar:Calendar; + private DEFAULT_TIMED_EVENT_DURATION = '01:00'; + private updatingDate:string|null = null; + private boundListener = this.dialogCloseListener.bind(this); + + async connect() { + const context = await window.OpenProject.getPluginContext(); + this.turboRequests = context.services.turboRequests; + this.pathHelper = context.services.pathHelperService; + + if (this.hasCalendarTarget && this.viewModeValue === 'calendar') { + this.initializeCalendar(); + } + + // handle dialog close event + document.addEventListener('dialog:close', this.boundListener); + } + + disconnect():void { + document.removeEventListener('dialog:close', this.boundListener); + + // Clean up calendar when controller disconnects + if (this.calendar) { + this.calendar.destroy(); + } + } + + initializeCalendar() { + this.calendar = new Calendar(this.calendarTarget, { + plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin], + initialView: this.calendarView(), + locales: allLocales, + locale: this.localeValue, + events: this.timeEntriesValue, + headerToolbar: false, + height: '100%', + initialDate: this.initialDateValue, + selectable: this.canCreateValue, + editable: this.canEditValue, + eventResizableFromStart: true, + defaultTimedEventDuration: this.DEFAULT_TIMED_EVENT_DURATION, + allDayContent: '', + dayMaxEventRows: 4, // 3 + more link + eventMinHeight: 30, + eventMaxStack: 2, + eventShortHeight: 31, + nowIndicator: true, + businessHours: { daysOfWeek: [1, 2, 3, 4, 5], startTime: '00:00', endTime: '24:00' }, + eventClassNames(arg) { + return [ + 'calendar-time-entry-event', + `__hl_status_${arg.event.extendedProps.statusId}`, + '__hl_border_top', + 'ellipsis', + ]; + }, + eventContent: (arg) => { + let timeDetails = ''; + + if (!arg.event.allDay) { + const time = `${moment(arg.event.start).format('LT')} - ${moment(arg.event.end).format('LT')}`; + timeDetails = `
${time}
`; + } + + return { + html: ` +
+
${this.displayDuration(arg.event.extendedProps.hours as number)}
+
+ +
${arg.event.extendedProps.projectName}
+ ${timeDetails} +
+
`, + }; + }, + select: (info) => { + let dialogParams = 'onlyMe=true'; + + if (info.allDay) { + dialogParams = `${dialogParams}&date=${info.startStr}`; + } else { + dialogParams = `${dialogParams}&startTime=${info.start.toISOString()}&endTime=${info.end.toISOString()}`; + } + + void this.turboRequests.request( + `${this.pathHelper.timeEntryDialog()}?${dialogParams}`, + { method: 'GET' }, + ); + }, + eventResize: (info) => { + // it does not make sense to resize the events without start & end times + // we cannot only disable resize, because we want to be able to drag the events + // so we need to revert the event to its original size + if (info.event.allDay) { + info.revert(); + return; + } + + const startMoment = moment(info.event.startStr); + const endMoment = moment(info.event.endStr); + + const newEventHours = moment.duration(endMoment.diff(startMoment)).asHours(); + + info.event.setExtendedProp('hours', newEventHours); + + this.updateTimeEntry( + info.event.id, + startMoment.format('YYYY-MM-DD'), + info.event.allDay ? null : startMoment.format('HH:mm'), + newEventHours, + info.revert, + ); + }, + + eventDragStart: (info) => { + // When dragging from all day into the calendar set the defaultTimedEventDuration to the hours of the event so + // that we display it correctly in the calendar. Will be reset in the drop event + if (info.event.allDay) { + this.calendar.setOption('defaultTimedEventDuration', moment.duration(info.event.extendedProps.hours as number, 'hours').asMilliseconds()); + } + }, + + eventAllow: (dropInfo, draggedEvent) => { + if (dropInfo.allDay && this.forceTimesValue) { + return false; + } + + if (!dropInfo.allDay && draggedEvent?.allDay && !this.allowTimesValue) { + return false; + } + + return true; + }, + + eventDrop: (info) => { + const startMoment = moment(info.event.startStr); + + this.updateTimeEntry( + info.event.id, + startMoment.format('YYYY-MM-DD'), + info.event.allDay ? null : startMoment.format('HH:mm'), + info.event.extendedProps.hours as number, + info.revert, + ); + + if (!info.event.allDay) { + info.event.setEnd( + startMoment + .add(info.event.extendedProps.hours as number, 'hours') + .toDate(), + ); + } + + this.calendar.setOption('defaultTimedEventDuration', this.DEFAULT_TIMED_EVENT_DURATION); + }, + eventClick: (info) => { + // check if we clicked on a link tag, if so exit early as we don't want to show the modal + if (info.jsEvent.target instanceof HTMLAnchorElement) { + return; + } + + void this.turboRequests.request( + `${this.pathHelper.timeEntryEditDialog(info.event.id)}?onlyMe=true`, + { method: 'GET' }, + ); + }, + viewDidMount: () => { setTimeout(() => this.addTotalFooter(), 100); }, + eventDidMount: () => { setTimeout(() => this.addTotalFooter(), 100); }, + eventChange: () => { setTimeout(() => this.addTotalFooter(), 100); }, + }); + + this.calendar.render(); + } + + addTotalFooter() { + if (!this.calendar) return; + const calendarScrollGridWrapper = document.querySelector('.fc-timegrid .fc-scrollgrid tbody'); + + if (!calendarScrollGridWrapper) return; + + // Remove existing footer if it exists + const existingFooter = document.querySelector('.fc-timegrid-footer-totals'); + if (existingFooter) { existingFooter.remove(); } + + const days:string[] = []; + document + .querySelectorAll('.fc-timegrid-cols .fc-day') + .forEach((dayElement) => { + days.push(dayElement.getAttribute('data-date') as string); + }); + + calendarScrollGridWrapper.appendChild(this.buildHtmlFooter(days)); + } + + calculateTotalHours(dayStr:string):number { + // Calculate total hours for this day + let totalHours = 0; + + this.calendar.getEvents().forEach((event) => { + const eventStart = event.start; + if (!eventStart) return; + + // Format event date for comparison + const eventDateStr = eventStart.toISOString().slice(0, 10); + + if (eventDateStr === dayStr && event.extendedProps && event.extendedProps.hours) { + totalHours += event.extendedProps.hours as number; + } + }); + + return totalHours; + } + + buildHtmlFooter(days:string[]):HTMLTableRowElement { + const tr = document.createElement('tr'); + tr.setAttribute('role', 'presentation'); + tr.className = 'fc-scrollgrid-section fc-timegrid-footer-totals'; + + const td = document.createElement('td'); + td.setAttribute('role', 'presentation'); + + const scrollerHarness = document.createElement('div'); + scrollerHarness.className = 'fc-scroller-harness'; + + const scroller = document.createElement('div'); + scroller.className = 'fc-scroller'; + scroller.style.overflow = 'hidden scroll'; + + const table = document.createElement('table'); + table.setAttribute('role', 'presentation'); + table.className = 'fc-col-footer'; + + const colgroup = document.createElement('colgroup'); + const col = document.createElement('col'); + const otherCol = document.querySelector('.fc-scrollgrid-section-header .fc-col-header col') as HTMLElement; + col.style.width = otherCol?.style?.width; + + const tbody = document.createElement('tbody'); + tbody.setAttribute('role', 'presentation'); + + const tbodyTr = document.createElement('tr'); + tbodyTr.setAttribute('role', 'row'); + + const th1 = document.createElement('th'); + th1.setAttribute('aria-hidden', 'true'); + th1.className = 'fc-timegrid-axis'; + + const axisFrame = document.createElement('div'); + axisFrame.className = 'fc-timegrid-axis-frame'; + + th1.appendChild(axisFrame); + tbodyTr.appendChild(th1); + + // Add columns for each day + days.forEach((day) => { + const footerCell = document.createElement('th'); + footerCell.setAttribute('role', 'columnfooter'); + footerCell.className = 'fc-col-footer-cell fc-day'; + + // Inner div in der zweiten Zelle erstellen + const syncInner = document.createElement('div'); + syncInner.className = 'fc-scrollgrid-sync-inner'; + syncInner.textContent = this.displayDuration( + this.calculateTotalHours(day), + ); + footerCell.appendChild(syncInner); + tbodyTr.appendChild(footerCell); + }); + + tbody.appendChild(tbodyTr); + colgroup.appendChild(col); + table.appendChild(colgroup); + table.appendChild(tbody); + scroller.appendChild(table); + scrollerHarness.appendChild(scroller); + td.appendChild(scrollerHarness); + tr.appendChild(td); + + return tr; + } + + updateTimeEntry(timeEntryId:string, spentOn:string, startTime:string | null, hours:number, revertFunction:() => void) { + const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || ''; + + fetch(this.pathHelper.timeEntryUpdate(timeEntryId), { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': csrfToken, + }, + body: JSON.stringify({ + time_entry: { + spent_on: spentOn, + start_time: startTime, + hours, + }, + no_dialog: true, + }), + }) + .then((response) => { + void response.text().then((html) => { + renderStreamMessage(html); + }); + if (!response.ok && revertFunction) { + revertFunction(); + } + }) + .catch(() => { + if (revertFunction) { + revertFunction(); + } + }); + } + + displayDuration(duration:number):string { + const hours = Math.floor(duration); + const minutes = Math.round((duration - hours) * 60); + + if (minutes === 0) { + return `${hours}h`; + } + if (hours === 0) { + return `${minutes}m`; + } + return `${hours}h ${minutes}m`; + } + + calendarView():string { + switch (this.modeValue) { + case 'week': + return 'timeGridWeek'; + case 'month': + return 'dayGridMonth'; + case 'day': + return 'timeGridDay'; + default: + return 'timeGridWeek'; + } + } + + newTimeEntry(event:ActionEvent) { + const dialogParams = `onlyMe=true&date=${event.params.date}`; + this.updatingDate = event.params.date as string; + + void this.turboRequests.request( + `${this.pathHelper.timeEntryDialog()}?${dialogParams}`, + { method: 'GET' }, + ); + } + + dialogCloseListener(event:CustomEvent):void { + const { detail: { dialog, submitted } } = event as { detail:{ dialog:HTMLDialogElement; submitted:boolean } }; + if (dialog.id !== 'time-entry-dialog' || !submitted) { return; } + + // we simply refresh the calendar page + if (this.viewModeValue === 'calendar') { + window.location.reload(); + return; + } + + // list view replaces only the updated date + if (this.viewModeValue === 'list') { + // we don't know what date we clicked, so we need to reload the whole page + if (this.updatingDate) { + void this.turboRequests.request(this.pathHelper.myTimeTrackingRefresh(this.updatingDate, this.viewModeValue, this.modeValue), { method: 'GET' }); + this.updatingDate = null; + } else { + window.location.reload(); + } + } + } +} diff --git a/lib/chronic_duration.rb b/lib/chronic_duration.rb index 94500124811..f3a03d6ef8a 100644 --- a/lib/chronic_duration.rb +++ b/lib/chronic_duration.rb @@ -28,8 +28,6 @@ # Changes to this file should be kept in sync with # frontend/src/app/shared/helpers/chronic_duration.js. -# rubocop:disable Metrics/AbcSize -# rubocop:disable Metrics/PerceivedComplexity module ChronicDuration extend self @@ -81,7 +79,7 @@ module ChronicDuration # Given an integer and an optional format, # returns a formatted string representing elapsed time # rubocop:disable Lint/UselessAssignment - def output(seconds, opts = {}) + def output(seconds, opts = {}) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity int = seconds.to_i seconds = int if seconds - int == 0 # if seconds end with .0 @@ -102,7 +100,7 @@ module ChronicDuration month = days_per_month * day year = SECONDS_PER_YEAR - if opts[:format] == :hours_only + if %i[hours_only hours_and_minutes].include?(opts[:format]) hours = seconds / 3600.0 seconds = 0 elsif seconds >= SECONDS_PER_YEAR && seconds % year < seconds % month @@ -184,6 +182,11 @@ module ChronicDuration hours = hours.round(2) hours_int = hours.to_i hours = hours_int if hours - hours_int == 0 # if hours end with .0 + when :hours_and_minutes + dividers = { hours: "h", minutes: "m", keep_zero: false } + + minutes = ((hours % 1) * 60).round + hours = hours.floor when :chrono dividers = { years: ":", months: ":", weeks: ":", days: ":", hours: ":", minutes: ":", seconds: ":", keep_zero: true @@ -281,7 +284,7 @@ module ChronicDuration end # Parse 3:41:59 and return 3 hours 41 minutes 59 seconds - def filter_by_type(string) + def filter_by_type(string) # rubocop:disable Metrics/AbcSize chrono_units_list = duration_units_list.reject { |v| v == "weeks" } if string.delete(" ")&.match?(time_matcher) @@ -308,7 +311,7 @@ module ChronicDuration # Get rid of unknown words and map found # words to defined time units - def filter_through_white_list(string, opts) + def filter_through_white_list(string, opts) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity res = [] string.split.each do |word| if word&.match?(float_matcher) @@ -369,5 +372,3 @@ module ChronicDuration %w[and with plus] end end -# rubocop:enable Metrics/AbcSize -# rubocop:enable Metrics/PerceivedComplexity diff --git a/lib/full_calendar/event.rb b/lib/full_calendar/event.rb new file mode 100644 index 00000000000..ee1c5e37ab9 --- /dev/null +++ b/lib/full_calendar/event.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module FullCalendar + class Event + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :id, :string + attribute :group_id, :string + attribute :all_day, :boolean, default: false + attribute :starts_at, :datetime + attribute :ends_at, :datetime + attribute :title, :string + attribute :url, :string + attribute :class_names, array: true, default: [] + + # override in subclasses to add more fields to the JSON + def additional_attributes + {} + end + + def as_json + { + "id" => id, + "groupId" => group_id, + "allDay" => all_day, + "start" => starts_at, + "end" => ends_at, + "title" => title, + "url" => url, + "classNames" => class_names + }.merge(additional_attributes).compact_blank.as_json + end + + def to_json(*) + as_json.to_json(*) + end + end +end diff --git a/modules/costs/app/components/_index.sass b/modules/costs/app/components/_index.sass index c2d6cebea24..2ceb251abf5 100644 --- a/modules/costs/app/components/_index.sass +++ b/modules/costs/app/components/_index.sass @@ -1 +1,3 @@ @import "time_entries/entry_dialog_component" +@import "my/time_tracking/calendar_component" +@import "my/time_tracking/list_component" diff --git a/modules/costs/app/components/my/time_tracking/calendar_component.html.erb b/modules/costs/app/components/my/time_tracking/calendar_component.html.erb new file mode 100644 index 00000000000..1fafb63fa90 --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/calendar_component.html.erb @@ -0,0 +1,13 @@ +<%= flex_layout(data: wrapper_data, classes: "my-time-tracking-calendar-view") do |calendar_page| %> + <% calendar_page.with_row { render(My::TimeTracking::HeaderComponent.new(mode:, date:, view_mode: :calendar)) } %> + <% calendar_page.with_row { render(My::TimeTracking::SubHeaderComponent.new(mode:, date:, view_mode: :calendar)) } %> + <% calendar_page.with_row(classes: "op-fc-wrapper", data: { "my--time-tracking-target" => "calendar" }) %> + + <% if mode != :day %> + <% calendar_page.with_row do %> + <%= flex_layout(align_items: :flex_end) do |flex| %> + <% flex.with_row(mt: 2, font_weight: :bold) { total_hours } %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/modules/costs/app/components/my/time_tracking/calendar_component.rb b/modules/costs/app/components/my/time_tracking/calendar_component.rb new file mode 100644 index 00000000000..8d229afcb62 --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/calendar_component.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module My + module TimeTracking + class CalendarComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + options time_entries: [], + mode: :week, + date: Date.current + + private + + def wrapper_data + { + "controller" => "my--time-tracking", + "application-target" => "dynamic", + "my--time-tracking-mode-value" => mode, + "my--time-tracking-view-mode-value" => "calendar", + "my--time-tracking-time-entries-value" => time_entries_json, + "my--time-tracking-initial-date-value" => date.iso8601, + "my--time-tracking-can-create-value" => User.current.allowed_in_any_project?(:log_own_time), + "my--time-tracking-can-edit-value" => User.current.allowed_in_any_project?(:edit_own_time_entries), + "my--time-tracking-allow-times-value" => TimeEntry.can_track_start_and_end_time?, + "my--time-tracking-force-times-value" => TimeEntry.must_track_start_and_end_time?, + "my--time-tracking-locale-value" => I18n.locale + } + end + + def time_entries_json + time_entries.map do |time_entry| + FullCalendar::TimeEntryEvent.from_time_entry(time_entry) + end.to_json + end + + def total_hours + total_hours = time_entries.sum(&:hours).round(2) + total_str = DurationConverter.output(total_hours, format: :hours_and_minutes).presence || t("label_no_time") + + I18n.t(mode, scope: "total_times", hours: total_str) + end + end + end +end diff --git a/modules/costs/app/components/my/time_tracking/calendar_component.sass b/modules/costs/app/components/my/time_tracking/calendar_component.sass new file mode 100644 index 00000000000..323dd3e210c --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/calendar_component.sass @@ -0,0 +1,31 @@ +.my-time-tracking-calendar-view + height: 100% + + .op-fc-wrapper + flex-grow: 1 + + .fc-timegrid-event-short + .fc-event-time::after + content: '' + display: none + + .calendar-time-entry-event + display: flex + user-select: none + border: 1px solid var(--borderColor-default) + border-radius: 5px + background: var(--body-background) + color: var(--body-font-color) + font-size: var(--body-font-size) + + .fc-popover + background: var(--fc-page-bg-color) !important + + .fc-daygrid-event + background: var(--body-background) !important + + .fc-event-title-container + margin: 0 !important + + .fc-event-time + font-weight: normal diff --git a/modules/costs/app/components/my/time_tracking/header_component.html.erb b/modules/costs/app/components/my/time_tracking/header_component.html.erb new file mode 100644 index 00000000000..bd4f8b7fd8b --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/header_component.html.erb @@ -0,0 +1,31 @@ +<%= render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title { I18n.t(:label_my_time_tracking) } + header.with_breadcrumbs( + [ + { href: home_path, text: helpers.organization_name }, + { text: I18n.t(:label_my_time_tracking), href: my_time_tracking_path } + ] + ) + + header.with_action_menu( + menu_arguments: { anchor_align: :end }, + button_arguments: { button_block: view_mode_block } + ) do |menu| + menu.with_item( + label: t(:label_calendar), + tag: :a, + href: my_time_tracking_path(date: date, mode: mode, view_mode: "calendar") + ) do |item| + item.with_leading_visual_icon(icon: :calendar) + end + menu.with_item( + label: t(:label_list), + tag: :a, + href: my_time_tracking_path(date: date, mode: mode, view_mode: "list") + ) do |item| + item.with_leading_visual_icon(icon: "list-unordered") + end + end + + header.with_action_zen_mode_button + end %> diff --git a/modules/costs/app/components/my/time_tracking/header_component.rb b/modules/costs/app/components/my/time_tracking/header_component.rb new file mode 100644 index 00000000000..91f70a3a9d9 --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/header_component.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module My + module TimeTracking + class HeaderComponent < ApplicationComponent + options :date, :mode, :view_mode + + def view_mode_block + if view_mode == :list + lambda do |button| + button.with_leading_visual_icon(icon: "list-unordered") + button.with_trailing_action_icon(icon: "triangle-down") + t(:label_list) + end + else + lambda do |button| + button.with_leading_visual_icon(icon: :calendar) + button.with_trailing_action_icon(icon: "triangle-down") + t(:label_calendar) + end + end + end + end + end +end diff --git a/modules/costs/app/components/my/time_tracking/list_component.html.erb b/modules/costs/app/components/my/time_tracking/list_component.html.erb new file mode 100644 index 00000000000..d08b7916ba2 --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/list_component.html.erb @@ -0,0 +1,21 @@ +<%= component_wrapper(tag: "div", data: wrapper_data) do %> + <%= render(My::TimeTracking::HeaderComponent.new(mode:, date:, view_mode: :list)) %> + <%= render(My::TimeTracking::SubHeaderComponent.new(mode:, date:, view_mode: :list)) %> + + <%= flex_layout(classes: ["my-time-tracking-list-view"]) do |flex| %> + <% range.each do |date| %> + <% flex.with_row do %> + <%= render(Primer::OpenProject::CollapsibleSection.new(collapsed: collapsed?(date))) do |section| %> + <% section.with_title { date_title(date) } %> + <% section.with_caption { date_caption(date) } %> + <% section.with_additional_information do %> + <%= render(My::TimeTracking::ListStatsComponent.new(time_entries: grouped_time_entries[date], date: date)) %> + <% end %> + <% section.with_collapsible_content do %> + <%= render(My::TimeTracking::ListWrapperComponent.new(time_entries: grouped_time_entries[date], date: date, mode: mode)) %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> diff --git a/modules/costs/app/components/my/time_tracking/list_component.rb b/modules/costs/app/components/my/time_tracking/list_component.rb new file mode 100644 index 00000000000..6062de42cc8 --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/list_component.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module My + module TimeTracking + class ListComponent < ApplicationComponent + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + options time_entries: [], + mode: :week, + date: Date.current + + private + + def wrapper_data + { + "controller" => "my--time-tracking", + "application-target" => "dynamic", + "my--time-tracking-mode-value" => mode, + "my--time-tracking-view-mode-value" => "list" + } + end + + def range + case mode + when :day then [date] + when :week then date.all_week + when :month then date.all_month.map(&:beginning_of_week).uniq + end + end + + def grouped_time_entries + @grouped_time_entries ||= time_entries + .group_by { |entry| mode == :month ? entry.spent_on.beginning_of_week : entry.spent_on } + .tap do |hash| + hash.default_proc = ->(h, k) { h[k] = [] } + end + end + + def date_title(date) + if mode == :month + I18n.t(:label_specific_week, week: date.strftime("%W")) + else + date.strftime("%A %d") + end + end + + def collapsed?(date) + date.past? + end + + def date_caption(date) + if mode == :month + if Date.current.beginning_of_week == date + t(:label_this_week) + elsif 1.week.ago.beginning_of_week == date + t(:label_last_week) + end + elsif date.today? + t(:label_today) + elsif date.yesterday? + t(:label_yesterday) + end + end + end + end +end diff --git a/modules/costs/app/components/my/time_tracking/list_component.sass b/modules/costs/app/components/my/time_tracking/list_component.sass new file mode 100644 index 00000000000..5b6fed818b9 --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/list_component.sass @@ -0,0 +1,2 @@ +.my-time-tracking-list-view + row-gap: var(--Layout-row-gap) diff --git a/modules/costs/app/components/my/time_tracking/list_stats_component.rb b/modules/costs/app/components/my/time_tracking/list_stats_component.rb new file mode 100644 index 00000000000..9082a81643d --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/list_stats_component.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module My + module TimeTracking + class ListStatsComponent < OpPrimer::BorderBoxTableComponent + include OpTurbo::Streamable + + options :time_entries, :date + + def wrapper_key + "time-entries-list-stats-#{date.iso8601}" + end + + def call + component_wrapper do + render(Primer::Beta::Text.new(color: :muted)) { "#{entry_count} - " } + + render(Primer::Beta::Text.new) { total_hours } + end + end + + def total_hours + total_hours = time_entries.sum(&:hours).round(2) + DurationConverter.output(total_hours, format: :hours_and_minutes).presence || "0h" + end + + def entry_count + entries_count = time_entries.size + "#{entries_count} #{TimeEntry.model_name.human(count: entries_count)}" + end + end + end +end diff --git a/modules/costs/app/components/my/time_tracking/list_wrapper_component.rb b/modules/costs/app/components/my/time_tracking/list_wrapper_component.rb new file mode 100644 index 00000000000..e56700131b9 --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/list_wrapper_component.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module My + module TimeTracking + class ListWrapperComponent < OpPrimer::BorderBoxTableComponent + include OpTurbo::Streamable + + options :time_entries, :date, :mode + + def wrapper_key + "time-entries-list-#{options[:date].iso8601}" + end + + def call + component_wrapper do + render(My::TimeTracking::TimeEntriesListComponent.new(rows: time_entries, date: date, mode: mode)) + end + end + end + end +end diff --git a/modules/costs/app/components/my/time_tracking/mode_switcher_component.rb b/modules/costs/app/components/my/time_tracking/mode_switcher_component.rb new file mode 100644 index 00000000000..7a9460c0a9c --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/mode_switcher_component.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module My + module TimeTracking + class ModeSwitcherComponent < ApplicationComponent + options :current_mode, + :view_mode, + :date + + def call + render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t(:label_meeting_date_time))) do |control| + %i[day week month].each do |mode| + control.with_item( + tag: :a, + href: my_time_tracking_path(date:, view_mode:, mode:), + icon: icon_for_mode(mode), + label: t("label_#{mode}"), + title: t("label_#{mode}"), + selected: (current_mode == mode) + ) + end + end + end + + def icon_for_mode(mode) + case mode + when :day + "op-calendar-day" + when :week + "op-calendar-week" + else + "calendar" + end + end + end + end +end diff --git a/modules/costs/app/components/my/time_tracking/sub_header_component.html.erb b/modules/costs/app/components/my/time_tracking/sub_header_component.html.erb new file mode 100644 index 00000000000..a823a822bb0 --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/sub_header_component.html.erb @@ -0,0 +1,30 @@ +<%= render(Primer::OpenProject::SubHeader.new) do |component| + component.with_filter_component do + render(My::TimeTracking::ModeSwitcherComponent.new(current_mode: mode, view_mode: view_mode, date: date)) + end + + component.with_text { title } + + component.with_action_component do + render(Primer::Beta::ButtonGroup.new) do |group| + group.with_button(icon: "arrow-left", tag: :a, **previous_attrs) + group.with_button(icon: "arrow-right", tag: :a, **next_attrs) + end + end + + component.with_action_button(tag: :a, href: today_href) do |button| + button.with_leading_visual_icon(icon: "op-calendar-day") + I18n.t(:label_today_capitalized) + end + + component.with_action_button( + scheme: :primary, + data: { "turbo-stream" => true }, + aria: { "label" => t(:button_log_time) }, + tag: :a, + href: dialog_time_entries_path(onlyMe: true) + ) do |button| + button.with_leading_visual_icon(icon: "plus") + t(:button_log_time) + end + end %> diff --git a/modules/costs/app/components/my/time_tracking/sub_header_component.rb b/modules/costs/app/components/my/time_tracking/sub_header_component.rb new file mode 100644 index 00000000000..daa5dc72d69 --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/sub_header_component.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module My + module TimeTracking + class SubHeaderComponent < ApplicationComponent + options :date, :mode, :view_mode + + def title # rubocop:disable Metrics/AbcSize + case mode + when :day + I18n.l(date, format: :long) + when :week + bow = date.beginning_of_week + eow = date.end_of_week + + if bow.year == eow.year && bow.month == eow.month + [I18n.l(bow, format: "%d."), I18n.l(eow, format: "%d. %B %Y")].join(" - ") + elsif bow.year == eow.year + [I18n.l(bow, format: "%d. %B"), I18n.l(eow, format: "%d. %B %Y")].join(" - ") + else + [I18n.l(bow, format: "%d. %B %Y"), I18n.l(eow, format: "%d. %B %Y")].join(" - ") + end + when :month + I18n.l(date, format: "%B %Y") + end + end + + def today_href + my_time_tracking_path(date: Date.current, view_mode:, mode:) + end + + def previous_attrs # rubocop:disable Metrics/AbcSize + case mode + when :day + { href: my_time_tracking_path(date: date - 1.day, view_mode:, mode:), + aria: { label: I18n.t(:label_previous_day) } } + when :week + { href: my_time_tracking_path(date: date - 1.week, view_mode:, mode:), + aria: { label: I18n.t(:label_previous_week) } } + when :month + { href: my_time_tracking_path(date: date - 1.month, view_mode:, mode:), + aria: { label: I18n.t(:label_previous_month) } } + end + end + + def next_attrs # rubocop:disable Metrics/AbcSize + case mode + when :day + { href: my_time_tracking_path(date: date + 1.day, view_mode:, mode:), + aria: { label: I18n.t(:label_next_day) } } + when :week + { href: my_time_tracking_path(date: date + 1.week, view_mode:, mode:), + aria: { label: I18n.t(:label_next_week) } } + when :month + { href: my_time_tracking_path(date: date + 1.month, view_mode:, mode:), + aria: { label: I18n.t(:label_next_month) } } + end + end + end + end +end diff --git a/modules/costs/app/components/my/time_tracking/time_entries_list_component.rb b/modules/costs/app/components/my/time_tracking/time_entries_list_component.rb new file mode 100644 index 00000000000..62375e62a41 --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/time_entries_list_component.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module My + module TimeTracking + class TimeEntriesListComponent < OpPrimer::BorderBoxTableComponent + include OpTurbo::Streamable + columns :spent_on, :time, :hours, :subject, :project, :activity, :comments + main_column :time, :subject, :project + + def row_class + TimeEntryRow + end + + def mobile_title + TimeEntry.model_name.human(count: 2) + end + + def has_actions? = true + + def action_row_header_content + return if options[:mode] == :month + + render(Primer::Beta::IconButton.new( + icon: "plus", + scheme: :invisible, + size: :small, + tag: :a, + tooltip_direction: :e, + href: "#", + data: { + action: "my--time-tracking#newTimeEntry", + "my--time-tracking-date-param" => options[:date] + }, + label: t("button_log_time"), + aria: { label: t("button_log_time") } + )) + end + + def headers + [ + options[:mode] == :month ? [:spent_on, { caption: TimeEntry.human_attribute_name(:spent_on) }] : nil, + TimeEntry.can_track_start_and_end_time? ? [:time, { caption: TimeEntry.human_attribute_name(:time) }] : nil, + [:hours, { caption: TimeEntry.human_attribute_name(:hours) }], + [:subject, { caption: TimeEntry.human_attribute_name(:subject) }], + [:project, { caption: TimeEntry.human_attribute_name(:project) }], + [:activity, { caption: TimeEntry.human_attribute_name(:activity) }], + [:comments, { caption: TimeEntry.human_attribute_name(:comments) }] + ].compact + end + + def skip_column?(column) + if column == :time + !TimeEntry.can_track_start_and_end_time? + elsif column == :spent_on + options[:mode] != :month + else + false + end + end + end + end +end diff --git a/modules/costs/app/components/my/time_tracking/time_entry_row.rb b/modules/costs/app/components/my/time_tracking/time_entry_row.rb new file mode 100644 index 00000000000..a5e604343a5 --- /dev/null +++ b/modules/costs/app/components/my/time_tracking/time_entry_row.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +module My + module TimeTracking + class TimeEntryRow < OpPrimer::BorderBoxRowComponent + def button_links + [ + action_menu + ] + end + + def action_menu + return nil unless User.current.allowed_in_work_package?(:edit_own_time_entries, time_entry.work_package) + + render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button(icon: "kebab-horizontal", "aria-label": t("label_more"), scheme: :invisible) + menu.with_item( + content_arguments: { + data: { + "turbo-stream" => true + } + }, + tag: :a, + label: t("label_edit"), + href: dialog_time_entry_path(time_entry, onlyMe: true) + ) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + end + + def spent_on + I18n.l(time_entry.spent_on) + end + + def time # rubocop:disable Metrics/AbcSize + return if time_entry.start_time.blank? + + times = [I18n.l(time_entry.start_timestamp, format: :time)] + + times << if time_entry.start_timestamp.to_date == time_entry.end_timestamp.to_date + I18n.l(time_entry.end_timestamp, format: :time) + else + I18n.l(time_entry.end_timestamp, format: :short) + end + + times.join(" - ") + end + + def hours + DurationConverter.output(time_entry.hours, format: :hours_and_minutes) + end + + def subject + render(Primer::Beta::Link.new(href: project_work_package_path(time_entry.project, time_entry.work_package), + underline: false)) do + "##{time_entry.work_package.id}" + end + " - #{time_entry.work_package.subject}" + end + + def project + render(Primer::Beta::Link.new(href: project_path(time_entry.project), underline: false)) do + time_entry.project.name + end + end + + def activity + time_entry.activity&.name + end + + delegate :comments, to: :time_entry + + private + + def time_entry + model + end + end + end +end diff --git a/modules/costs/app/controllers/my/time_tracking_controller.rb b/modules/costs/app/controllers/my/time_tracking_controller.rb new file mode 100644 index 00000000000..acd1a945dfb --- /dev/null +++ b/modules/costs/app/controllers/my/time_tracking_controller.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module My + class TimeTrackingController < ApplicationController + include OpTurbo::ComponentStream + + before_action :require_login, :view_mode, :mode, :date + + no_authorization_required!(:index, :refresh) + + menu_item :my_time_tracking + + layout "global" + + helper_method :list_view_component + + def index + case mode + when :day then load_time_entries(date) + when :week then load_time_entries(date.all_week) + when :month then load_time_entries(date.all_month) + end + end + + def refresh + if mode == :month # for the month we have the whole week in the table, for the rest it's the day + load_time_entries(date.all_week) + else + load_time_entries(date) + end + + update_via_turbo_stream( + component: My::TimeTracking::ListWrapperComponent.new(time_entries: @time_entries, date: date, mode: mode) + ) + update_via_turbo_stream( + component: My::TimeTracking::ListStatsComponent.new(time_entries: @time_entries, date: date) + ) + + respond_with_turbo_streams + end + + private + + def date + @date ||= parsed_date || current_date + end + + def parsed_date + if params[:date].present? + begin + Date.iso8601(params[:date]) + rescue StandardError + nil + end + end + end + + def default_mode + if mobile? + "day" + else + "week" + end + end + + def mode + @mode ||= (params[:mode].presence || default_mode).to_sym + end + + def default_view_mode + if TimeEntry.can_track_start_and_end_time? + "calendar" + else + "list" + end + end + + def view_mode + @view_mode ||= (params[:view_mode].presence || default_view_mode).to_sym + end + + def current_date + case mode + when :day then Time.zone.today + when :week then Time.zone.today.beginning_of_week + when :month then Time.zone.today.beginning_of_month + end + end + + def load_time_entries(time_scope) + @time_entries = TimeEntry + .includes(:project, :activity, { work_package: :status }) + .where(project_id: Project.visible.select(:id)) + .where(user: User.current, spent_on: time_scope) + .order(:spent_on, :start_time, :hours) + end + + def list_view_component + if view_mode == :list + My::TimeTracking::ListComponent.new( + time_entries: @time_entries, + mode: mode, + date: date + ) + else + My::TimeTracking::CalendarComponent.new( + time_entries: @time_entries, + mode: mode, + date: date + ) + end + end + + def mobile? + browser.device.mobile? + end + + def default_breadcrumb; end + + def show_local_breadcrumb + false + end + end +end diff --git a/modules/costs/app/controllers/my/timer_controller.rb b/modules/costs/app/controllers/my/timer_controller.rb index d3bde9cd283..f9d070ae722 100644 --- a/modules/costs/app/controllers/my/timer_controller.rb +++ b/modules/costs/app/controllers/my/timer_controller.rb @@ -33,6 +33,8 @@ module My before_action :require_login before_action :find_active_timer, only: %i[show] + no_authorization_required! :show + def show render layout: nil end diff --git a/modules/costs/app/controllers/time_entries_controller.rb b/modules/costs/app/controllers/time_entries_controller.rb index f7375086437..9dfce83a6c8 100644 --- a/modules/costs/app/controllers/time_entries_controller.rb +++ b/modules/costs/app/controllers/time_entries_controller.rb @@ -49,7 +49,7 @@ class TimeEntriesController < ApplicationController @show_user = show_user_input_in_dialog @limit_to_project_id = @project&.id - @time_entry.spent_on ||= params[:date].presence || Time.zone.today + prefill_time_entry_from_params end def user_tz_caption @@ -97,19 +97,24 @@ class TimeEntriesController < ApplicationController end end - def update + def update # rubocop:disable Metrics/AbcSize call = TimeEntries::UpdateService .new(user: current_user, model: @time_entry) .call(permitted_params.time_entries) @time_entry = call.result - unless call.success? - form_component = TimeEntries::TimeEntryFormComponent.new(time_entry: @time_entry, **form_config_options) - update_via_turbo_stream(component: form_component, status: :bad_request) - - respond_with_turbo_streams + if call.failure? + if params[:no_dialog] + render_error_flash_message_via_turbo_stream(message: t("notice_time_entry_update_failed", + errors: call.errors.full_messages.join(", "))) + else + form_component = TimeEntries::TimeEntryFormComponent.new(time_entry: @time_entry, **form_config_options) + update_via_turbo_stream(component: form_component, status: :bad_request) + end end + + respond_with_turbo_streams(status: call.success? ? :ok : :bad_request) end def destroy @@ -123,12 +128,37 @@ class TimeEntriesController < ApplicationController form_component = TimeEntries::TimeEntryFormComponent.new(time_entry: @time_entry, **form_config_options) update_via_turbo_stream(component: form_component, status: :bad_request) end - - respond_with_turbo_streams end private + def prefill_time_entry_from_params # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity + # correct time calcuation needs a time zone + @time_entry.time_zone ||= User.current.time_zone.name + + if params[:date].present? + @time_entry.spent_on = params[:date] + elsif params[:startTime].present? && params[:endTime].present? + parsed_start_time = DateTime.parse(params[:startTime]).in_time_zone(User.current.time_zone) + parsed_end_time = DateTime.parse(params[:endTime]).in_time_zone(User.current.time_zone) + + @time_entry.spent_on = parsed_start_time.to_date + + # FullCalendar sends the same time for start and end if the event is an "all-day event" or + # in our case "no speicific time" + if parsed_start_time != parsed_end_time + @time_entry.start_time = (parsed_start_time.hour * 60) + parsed_start_time.min + @time_entry.hours = ((parsed_end_time - parsed_start_time) / 1.hour).round(2) + end + else + @time_entry.spent_on ||= Time.zone.today + end + + if params[:removeTime] == "true" + @time_entry.start_time = nil + end + end + def show_user_input_in_dialog return false if params[:onlyMe] == "true" diff --git a/modules/costs/app/models/time_entry.rb b/modules/costs/app/models/time_entry.rb index c0ab8b203b5..552d007f068 100644 --- a/modules/costs/app/models/time_entry.rb +++ b/modules/costs/app/models/time_entry.rb @@ -147,6 +147,10 @@ class TimeEntry < ApplicationRecord (user_id == usr.id && usr.allowed_in_project?(:view_own_hourly_rate, project)) end + def has_start_and_end_time? + start_time.present? + end + def start_timestamp # rubocop:disable Metrics/AbcSize return nil if start_time.blank? return nil if time_zone.blank? diff --git a/modules/costs/app/views/my/time_tracking/index.html.erb b/modules/costs/app/views/my/time_tracking/index.html.erb new file mode 100644 index 00000000000..b3f3712cf7d --- /dev/null +++ b/modules/costs/app/views/my/time_tracking/index.html.erb @@ -0,0 +1 @@ +<%= render(list_view_component) %> diff --git a/modules/costs/config/locales/en.yml b/modules/costs/config/locales/en.yml index dd77b2529e8..f27e8a5b660 100644 --- a/modules/costs/config/locales/en.yml +++ b/modules/costs/config/locales/en.yml @@ -59,12 +59,14 @@ en: project: Project user: User work_package: Work package + subject: Subject hours: Hours comments: Comment activity: Activity spent_on: Date start_time: Start time end_time: Finish time + time: Time models: time_entry: one: "Time entry" @@ -164,6 +166,21 @@ en: label_cost: "Cost" label_costs: "Costs" label_mandatory_fields: "Mandatory fields" + label_my_time_tracking: "My time tracking" + label_no_time: "No time tracked" + label_next_day: "Next day" + label_next_week: "Next week" + label_next_month: "Next month" + label_previous_day: "Previous day" + label_previous_week: "Previous week" + label_previous_month: "Previous month" + label_specific_day: "Day %{day}" + label_specific_week: "Calendar week %{week}" + label_specific_month: "Month %{month}" + label_list: "List" + label_month: "Month" + label_day: "Day" + label_today_capitalized: "Today" placeholder_activity_select_work_package_first: Work package selection is required first @@ -172,6 +189,7 @@ en: notice_successful_lock: "Locked successfully." notice_cost_logged_successfully: "Unit cost logged successfully." notice_different_time_zones: "This user has a different time zone (%{tz}). Time will be logged using their time zone." + notice_time_entry_update_failed: "Failed to update time entry. Errors: %{errors}" permission_edit_cost_entries: "Edit booked unit costs" permission_edit_own_cost_entries: "Edit own booked unit costs" @@ -203,6 +221,11 @@ en: week: "week" + total_times: + week: "Week Total: %{hours}" + month: "Month Total: %{hours}" + day: "Day Total: %{hours}" + api_v3: errors: validation: diff --git a/modules/costs/config/locales/js-en.yml b/modules/costs/config/locales/js-en.yml index 98c4e437a87..4c60e29a0d4 100644 --- a/modules/costs/config/locales/js-en.yml +++ b/modules/costs/config/locales/js-en.yml @@ -29,6 +29,8 @@ en: js: text_are_you_sure: "Are you sure?" + myTimeTracking: + noSpecificTime: "No specific time" work_packages: property_groups: costs: "Costs" diff --git a/modules/costs/config/routes.rb b/modules/costs/config/routes.rb index dc129df6c34..856b91dc7e4 100644 --- a/modules/costs/config/routes.rb +++ b/modules/costs/config/routes.rb @@ -47,8 +47,18 @@ Rails.application.routes.draw do get "/time_entries/dialog" => "time_entries#dialog" end - scope "my" do - get "/timer" => "my/timer#show", as: "my_timers" + namespace "my" do + get "/timer" => "timer#show", as: "timers" + + get "/time-tracking/(:mode-:view_mode)(/:date)" => "time_tracking#index", + as: :time_tracking, + constraints: { + mode: /day|week|month/, + view_mode: /list|calendar/, + date: /\d{4}-\d{2}-\d{2}/ + } + get "/time-tracking/refresh" => "time_tracking#refresh", + as: :time_tracking_refresh end scope "projects/:project_id", as: "project", module: "projects" do diff --git a/modules/costs/lib/costs/engine.rb b/modules/costs/lib/costs/engine.rb index 32538e828fe..f62be67afc3 100644 --- a/modules/costs/lib/costs/engine.rb +++ b/modules/costs/lib/costs/engine.rb @@ -34,10 +34,7 @@ module Costs include OpenProject::Plugins::ActsAsOpEngine - register "costs", - author_url: "https://www.openproject.org", - bundled: true, - settings: { menu_item: :costs_settings } do + register "costs", author_url: "https://www.openproject.org", bundled: true, settings: { menu_item: :costs_settings } do project_module :costs do permission :view_time_entries, {}, @@ -144,6 +141,13 @@ module Costs if: ->(*) { User.current.admin? }, parent: :admin_costs, caption: :enumeration_activities + + menu :global_menu, + :my_time_tracking, + { controller: "/my/time_tracking", action: "index" }, + after: :my_page, + caption: :label_my_time_tracking, + icon: :stopwatch end initializer "costs.settings" do diff --git a/modules/costs/lib/costs/patches/permitted_params_patch.rb b/modules/costs/lib/costs/patches/permitted_params_patch.rb index c3d4d269c68..6a770885345 100644 --- a/modules/costs/lib/costs/patches/permitted_params_patch.rb +++ b/modules/costs/lib/costs/patches/permitted_params_patch.rb @@ -68,7 +68,7 @@ module Costs::Patches::PermittedParamsPatch def time_entries additional_fields = [] - additional_fields << :start_time if TimeEntry.can_track_start_and_end_time? + additional_fields << :start_time if TimeEntry.can_track_start_and_end_time? || params.dig(:time_entry, :start_time).nil? params .require(:time_entry) diff --git a/modules/costs/lib/full_calendar/time_entry_event.rb b/modules/costs/lib/full_calendar/time_entry_event.rb new file mode 100644 index 00000000000..72bac191a54 --- /dev/null +++ b/modules/costs/lib/full_calendar/time_entry_event.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module FullCalendar + class TimeEntryEvent < Event + attr_accessor :time_entry + + class << self + def from_time_entry(time_entry) + event = new( + id: time_entry.id, + starts_at: time_entry.start_timestamp || time_entry.spent_on, + ends_at: time_entry.end_timestamp || time_entry.spent_on, + all_day: time_entry.start_time.blank?, + title: "#{time_entry.project.name}: ##{time_entry.work_package.id} #{time_entry.work_package.subject}" + ) + event.time_entry = time_entry + + event + end + end + + def additional_attributes + { + hours: time_entry.hours, + statusId: time_entry.work_package.status_id, + workPackageId: time_entry.work_package.id, + workPackageSubject: time_entry.work_package.subject, + projectId: time_entry.project.id, + projectName: time_entry.project.name + } + end + end +end diff --git a/modules/costs/spec/controllers/my/time_tracking_controller_spec.rb b/modules/costs/spec/controllers/my/time_tracking_controller_spec.rb new file mode 100644 index 00000000000..eefcebe57c0 --- /dev/null +++ b/modules/costs/spec/controllers/my/time_tracking_controller_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require_relative "../../spec_helper" + +RSpec.describe My::TimeTrackingController do + let(:user) { create(:user) } + + before do + login_as user + end + + describe "GET /my/time-tracking" do + context "when requesting on a non mobile device" do + before do + allow(controller).to receive(:mobile?).and_return(false) + end + + context "and tracking start and end times is enabled" do + before do + allow(TimeEntry).to receive(:can_track_start_and_end_time?).and_return(true) + end + + it "renders the calendar week view" do + get :index + expect(assigns(:mode)).to eq(:week) + expect(assigns(:view_mode)).to eq(:calendar) + end + end + + context "and tracking start and end times is disabled" do + before do + allow(TimeEntry).to receive(:can_track_start_and_end_time?).and_return(false) + end + + it "renders the week list view" do + get :index + expect(assigns(:mode)).to eq(:week) + expect(assigns(:view_mode)).to eq(:list) + end + end + end + + context "when requesting on a mobile device" do + before do + allow(controller).to receive(:mobile?).and_return(true) + end + + context "and tracking start and end times is enabled" do + before do + allow(TimeEntry).to receive(:can_track_start_and_end_time?).and_return(true) + end + + it "renders the day calendar view" do + get :index + expect(assigns(:mode)).to eq(:day) + expect(assigns(:view_mode)).to eq(:calendar) + end + end + + context "and tracking start and end times is disabled" do + before do + allow(TimeEntry).to receive(:can_track_start_and_end_time?).and_return(false) + end + + it "renders the day list view" do + get :index + expect(assigns(:mode)).to eq(:day) + expect(assigns(:view_mode)).to eq(:list) + end + end + end + end + + describe "GET /my/time-tracking/day" do + it "without a date param it uses the current date" do + get :index, params: { mode: :day } + expect(assigns(:date)).to eq(Date.current) + end + + it "with a date param it uses the given date" do + get :index, params: { mode: :day, date: "2023-12-31" } + expect(assigns(:date)).to eq(Date.parse("2023-12-31")) + end + + it "with an invalid date param it uses the current date" do + get :index, params: { mode: :day, date: "invalid-date" } + expect(assigns(:date)).to eq(Date.current) + end + end + + describe "GET /my/time-tracking/week" do + it "without a date param it uses the beginning of current week" do + get :index, params: { mode: :week } + expect(assigns(:date)).to eq(Date.current.beginning_of_week) + end + + it "with a date param it uses the given date" do + get :index, params: { mode: :week, date: "2023-12-31" } + expect(assigns(:date)).to eq(Date.parse("2023-12-31")) + end + + it "with an invalid date param it uses the beginning of current week" do + get :index, params: { mode: :week, date: "invalid-date" } + expect(assigns(:date)).to eq(Date.current.beginning_of_week) + end + end + + describe "GET /my/time-tracking/month" do + it "without a date param it uses the beginning of current month" do + get :index, params: { mode: :month } + expect(assigns(:date)).to eq(Date.current.beginning_of_month) + end + + it "with a date param it uses the given date" do + get :index, params: { mode: :month, date: "2023-12-31" } + expect(assigns(:date)).to eq(Date.parse("2023-12-31")) + end + + it "with an invalid date param it uses the beginning of current month" do + get :index, params: { mode: :month, date: "invalid-date" } + expect(assigns(:date)).to eq(Date.current.beginning_of_month) + end + end +end diff --git a/modules/costs/spec/features/my_time_tracking_spec.rb b/modules/costs/spec/features/my_time_tracking_spec.rb new file mode 100644 index 00000000000..c8b19270680 --- /dev/null +++ b/modules/costs/spec/features/my_time_tracking_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require_relative "../spec_helper" + +RSpec.describe "my time tracking", :js do + let(:time_zone) { "Etc/UTC" } + let(:user) do + create(:user, + preferences: { time_zone: }, + member_with_permissions: { + project1 => %i[view_project view_time_entries log_own_time edit_own_time_entries], + project2 => %i[view_project view_time_entries log_own_time] + }) + end + + let(:project1) { create(:project_with_types) } + let(:project2) { create(:project_with_types) } + let(:work_package1) { create(:work_package, project: project1) } + let(:work_package2) { create(:work_package, project: project2) } + let!(:time_entry1) { create(:time_entry, user:, work_package: work_package1, spent_on: "2025-04-07", hours: 6.5) } + let!(:time_entry2) do + create(:time_entry, work_package: work_package1, user:, spent_on: "2025-04-09", hours: 1.0, start_time: (9 * 60) + 30, + time_zone:) + end + let!(:time_entry3) { create(:time_entry, user:, work_package: work_package1, spent_on: "2025-04-14", hours: 3.0) } + let!(:time_entry4) { create(:time_entry, user:, work_package: work_package2, spent_on: "2025-04-09", hours: 2.0) } + let!(:time_entry5) do + create(:time_entry, user:, work_package: work_package2, spent_on: "2025-04-09", hours: 1.0, start_time: (13 * 60) + 30, + time_zone:) + end + let!(:time_entry6) { create(:time_entry, user:, work_package: work_package2, spent_on: "2025-04-14", hours: 2.0) } + + before do + login_as user + end + + it "does something" do + allow(TimeEntry).to receive(:can_track_start_and_end_time?).and_return(true) + visit my_time_tracking_path(date: "2025-04-09", view_mode: "list") + # save_and_open_screenshot + end +end diff --git a/spec/lib/chronic_duration_spec.rb b/spec/lib/chronic_duration_spec.rb index b47039765c5..a1f5f9d75ce 100644 --- a/spec/lib/chronic_duration_spec.rb +++ b/spec/lib/chronic_duration_spec.rb @@ -31,7 +31,7 @@ require "rspec_helper" require "chronic_duration" -INACCURATE_FORMATS = %i[days_and_hours hours_only].freeze +INACCURATE_FORMATS = %i[days_and_hours hours_only hours_and_minutes].freeze RSpec.describe ChronicDuration do describe ".parse" do @@ -151,6 +151,7 @@ RSpec.describe ChronicDuration do long: "1 minute 20 seconds", days_and_hours: "0.02h", hours_only: "0.02h", + hours_and_minutes: "1m", chrono: "1:20" }, (60 + 20.51) => @@ -161,6 +162,7 @@ RSpec.describe ChronicDuration do long: "1 minute 20.51 seconds", days_and_hours: "0.02h", hours_only: "0.02h", + hours_and_minutes: "1m", chrono: "1:20.51" }, (60 + 20.51928) => @@ -171,6 +173,7 @@ RSpec.describe ChronicDuration do long: "1 minute 20.51928 seconds", days_and_hours: "0.02h", hours_only: "0.02h", + hours_and_minutes: "1m", chrono: "1:20.51928" }, ((4 * 3600) + 60 + 1) => @@ -181,6 +184,7 @@ RSpec.describe ChronicDuration do long: "4 hours 1 minute 1 second", days_and_hours: "4.02h", hours_only: "4.02h", + hours_and_minutes: "4h 1m", chrono: "4:01:01" }, ((2 * 3600) + (20 * 60)) => @@ -191,6 +195,7 @@ RSpec.describe ChronicDuration do long: "2 hours 20 minutes", days_and_hours: "2.33h", hours_only: "2.33h", + hours_and_minutes: "2h 20m", chrono: "2:20:00" }, ((8 * 24 * 3600) + (3 * 3600) + (30 * 60)) => @@ -201,6 +206,7 @@ RSpec.describe ChronicDuration do long: "8 days 3 hours 30 minutes", days_and_hours: "8d 3.5h", hours_only: "195.5h", + hours_and_minutes: "195h 30m", chrono: "8:03:30:00" }, ((6 * 30 * 24 * 3600) + (24 * 3600)) => @@ -211,6 +217,7 @@ RSpec.describe ChronicDuration do long: "6 months 1 day", days_and_hours: "181d 0h", hours_only: "4344h", + hours_and_minutes: "4344h", chrono: "6:01:00:00:00" # Yuck. FIXME }, ((365.25 * 24 * 3600) + (24 * 3600)).to_i => @@ -221,6 +228,7 @@ RSpec.describe ChronicDuration do long: "1 year 1 day", days_and_hours: "366d 0h", hours_only: "8790h", + hours_and_minutes: "8790h", chrono: "1:00:01:00:00:00" }, ((3 * 365.25 * 24 * 3600) + (24 * 3600)).to_i => @@ -231,6 +239,7 @@ RSpec.describe ChronicDuration do long: "3 years 1 day", days_and_hours: "1096d 0h", hours_only: "26322h", + hours_and_minutes: "26322h", chrono: "3:00:01:00:00:00" }, ((6 * 365.25 * 24 * 3600) + (3 * 3600)).to_i => @@ -241,6 +250,7 @@ RSpec.describe ChronicDuration do long: "6 years 3 hours", days_and_hours: "2191d 3h", hours_only: "52599h", + hours_and_minutes: "52599h", chrono: "6:00:00:03:00:00" }, (3600 * 24 * 30 * 18) => @@ -251,6 +261,7 @@ RSpec.describe ChronicDuration do long: "18 months", days_and_hours: "540d 0h", hours_only: "12960h", + hours_and_minutes: "12960h", chrono: "18:00:00:00:00" } }