mirror of
https://github.com/opf/openproject.git
synced 2026-06-13 19:20:00 +00:00
Merge pull request #18104 from opf/feature/59376-separate-time-tracking-module-with-calendar-view-for-logged-time-with-start-and-finish-time
[#59376] Separate time tracking module with calendar view for logged time with start and finish time
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,7 +27,7 @@ See COPYRIGHT and LICENSE files for more details.
|
||||
|
||||
++#%>
|
||||
|
||||
<div class="generic-table--container <%= container_class %>" data-test-selector="<%= test_selector %>">
|
||||
<div class="generic-table--container <%= container_class %>" data-test-selector="<%= test_selector %>" id="<%= container_id %>">
|
||||
<div class="generic-table--results-container">
|
||||
<table class="generic-table" data-controller="table-highlighting">
|
||||
<colgroup>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<HTMLElement> {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = `<div class="color-fg-muted mt-2" title="${time}">${time}</div>`;
|
||||
}
|
||||
|
||||
return {
|
||||
html: `
|
||||
<div class="fc-event-main-frame">
|
||||
<div class="fc-event-time mb-1">${this.displayDuration(arg.event.extendedProps.hours as number)}</div>
|
||||
<div class="fc-event-title-container">
|
||||
<div class="fc-event-title mb-2" title="${arg.event.extendedProps.workPackageSubject}">
|
||||
<a class="Link--primary Link" href="${this.pathHelper.workPackageShortPath(arg.event.extendedProps.workPackageId as string)}">
|
||||
${arg.event.extendedProps.workPackageSubject}
|
||||
</a>
|
||||
</div>
|
||||
<div class="color-fg-muted" title="${arg.event.extendedProps.projectName}">${arg.event.extendedProps.projectName}</div>
|
||||
${timeDetails}
|
||||
</div>
|
||||
</div>`,
|
||||
};
|
||||
},
|
||||
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<HTMLMetaElement>('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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -1 +1,3 @@
|
||||
@import "time_entries/entry_dialog_component"
|
||||
@import "my/time_tracking/calendar_component"
|
||||
@import "my/time_tracking/list_component"
|
||||
|
||||
@@ -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 %>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
.my-time-tracking-list-view
|
||||
row-gap: var(--Layout-row-gap)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 %>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<%= render(list_view_component) %>
|
||||
@@ -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:
|
||||
|
||||
@@ -29,6 +29,8 @@
|
||||
en:
|
||||
js:
|
||||
text_are_you_sure: "Are you sure?"
|
||||
myTimeTracking:
|
||||
noSpecificTime: "No specific time"
|
||||
work_packages:
|
||||
property_groups:
|
||||
costs: "Costs"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user