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"
}
}