diff --git a/app/assets/javascripts/worktimes.js.coffee b/app/assets/javascripts/worktimes.js.coffee index 57ca676e8..9d7c02bb9 100644 --- a/app/assets/javascripts/worktimes.js.coffee +++ b/app/assets/javascripts/worktimes.js.coffee @@ -29,6 +29,10 @@ app.worktimes = new class $('#multi').hide() e.preventDefault() if e + parseDate = (dateStr) -> + [d, m, y] = dateStr.split('.') + new Date "#{y}-#{m}-#{d}" + init: -> @bind() @initWaypoint() @@ -75,8 +79,12 @@ app.worktimes = new class else if $('#new_absencetime').length showRegularAbsence(null) + if $('#ordertime_repetitions').val() + @recalcMaxRepetitions() + $('#multi_absence_link').click(showMultiAbsence) $('#regular_absence_link').click(showRegularAbsence) + $('#ordertime_work_date').change(@recalcMaxRepetitions) initWaypoint: -> if worktimesWaypoint @@ -161,5 +169,22 @@ app.worktimes = new class setTimeout((-> entries.removeClass('highlight')), 400) ) + # Calculates max amount of ordertime repetitions based on weekday + # Monday -> 5, Tuesday -> 4, ... + recalcMaxRepetitions: () -> + repField = $('#ordertime_repetitions') + dateStr = $('#ordertime_work_date').val() + + weekDay = parseDate(dateStr).getDay() + max = switch weekDay + when 0, 6 then 1 # Sunday, Saturday + else 6 - weekDay # Weekdays + + currentVal = Number(repField.val()) + repField.attr('max', max) + # Repetitions must not be higher than new max value + repField.val(Math.min(currentVal, max)) + + $(document).on 'turbolinks:load', -> app.worktimes.init() diff --git a/app/controllers/ordertimes_controller.rb b/app/controllers/ordertimes_controller.rb index 88cb5d0c2..7f267eff1 100644 --- a/app/controllers/ordertimes_controller.rb +++ b/app/controllers/ordertimes_controller.rb @@ -11,6 +11,31 @@ class OrdertimesController < WorktimesController after_destroy :send_email_notification + def create + repetitions = params[:ordertime][:repetitions].to_i.nonzero? || 1 + if repetitions > 1 + @multi_form = Forms::MultiOrdertime.new(params.require(:ordertime).permit(permitted_attrs + [:repetitions])) + @multi_form.employee = Employee.find_by(id: employee_id) + + @multi_form.prepare_each.with_index do |ot_params, idx| + @_params = ot_params + # Otherwise only one entity will be created and afterward updated with each cycle + model_ivar_set(Ordertime.new) + + is_last = idx == repetitions - 1 + super do |format| + if is_last + flash[:notice] = "#{repetitions} Arbeitszeiten wurden erfasst" + else + format.html + end + end + end + else + super + end + end + def update if entry.employee_id == @user.id && !entry.worktimes_committed? super diff --git a/app/domain/forms/multi_ordertime.rb b/app/domain/forms/multi_ordertime.rb new file mode 100644 index 000000000..48f5a8b57 --- /dev/null +++ b/app/domain/forms/multi_ordertime.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +# Copyright (c) 2006-2025, Puzzle ITC GmbH. This file is part of +# PuzzleTime and licensed under the Affero General Public License version 3 +# or later. See the COPYING file at the top-level directory or at +# https://github.com/puzzle/puzzletime. + +module Forms + class MultiOrdertime + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :account_id, :integer + attribute :ticket, :string + attribute :description, :string + attribute :internal_description, :string + attribute :work_date, :date + attribute :repetitions, :integer, default: 1 + attribute :hours, :decimal + attribute :billable, :boolean + attribute :from_start_time, :string + attribute :to_end_time, :string + + attr_accessor :employee + + validates :work_date, presence: true + validates :repetitions, + presence: true, + numericality: { + only_integer: true, + greater_than_or_equal_to: 1, + less_than_or_equal_to: ->(form) { form.max_allowed_repetitions } + }, + if: :work_date + + def prepare_each + return enum_for(:prepare_each) unless block_given? + + period.step do |date| + employment = employee.employment_at(date) + next unless employment + + yield build_params(date) + end + end + + def max_allowed_repetitions + return 1 unless work_date + + case work_date.wday + when 0, 6 # Sunday, Saturday + 1 + else # Weekdays + 6 - work_date.wday + end + end + + private + + def end_date + work_date + repetitions - 1 + end + + def period + Period.new(work_date, end_date) + end + + def build_params(date) + p = attributes.except('repetitions') + p['work_date'] = date + + ActionController::Parameters.new(ordertime: p) + end + end +end diff --git a/app/views/ordertimes/_form.html.haml b/app/views/ordertimes/_form.html.haml index ce3ffe304..ac04c361d 100644 --- a/app/views/ordertimes/_form.html.haml +++ b/app/views/ordertimes/_form.html.haml @@ -40,10 +40,16 @@ = f.labeled_input_field(:ticket, span: 2) = f.labeled_text_area(:description) = f.labeled_text_area(:internal_description, html_options = {title: "Wird innerhalb von PuzzleTime und im CSV-Export angezeigt, ist im Zeitrapport jedoch nicht enthalten", data: {toggle: :tooltip}, rows: 2},) - = f.labeled_date_field(:work_date, + .form-group + = f.label(:work_date, class: 'col-md-2 control-label') + .col-md-2 + = f.date_field(:work_date, data: { remote: true, url: url_for(action: 'existing'), dynamic_params: 'ordertime[employee_id]' }) + = f.label(:repetitions, 'Wiederholungen an Folgetagen', class: 'col-md-2 control-label') + .col-md-1 + = f.number_field(:repetitions, html_options = {title: "Erstellt Kopien für aufeinanderfolgende Tage ab dem gewähltem Datum", data: {toggle: :tooltip}, value: 1, min: 1, max: 5, step: 1}) .form-group = f.label(:hours, class: 'col-md-2 control-label') .col-md-1 diff --git a/test/controllers/ordertimes_controller_test.rb b/test/controllers/ordertimes_controller_test.rb index bfd1cb20b..9233a6dbf 100644 --- a/test/controllers/ordertimes_controller_test.rb +++ b/test/controllers/ordertimes_controller_test.rb @@ -233,6 +233,34 @@ def test_create_with_zero_percent_employment assert_match(/unbezahlter Urlaub/, flash[:warning]) end + def test_create_repeated_ordertimes + reps = 3 + work_date = Date.parse('2026-01-05') + login_as :long_time_john + + assert_difference 'Ordertime.count', reps, "Es sollten genau #{reps} Einträge erstellt werden" do + post :create, params: { + ordertime: { + account_id: work_items(:allgemein), + work_date: work_date, + hours: '5:30', + employee: employees(:long_time_john), + repetitions: reps, + description: 'Repeated worktime' + } + } + end + + assert_response :redirect + assert_redirected_to ordertimes_path(week_date: work_date + reps - 1) + assert_equal "#{reps} Arbeitszeiten wurden erfasst", flash[:notice] + + created_dates = Ordertime.last(reps).map(&:work_date) + expected_dates = (0..reps - 1).map { |i| work_date + i } + + assert_equal expected_dates, created_dates + end + def test_create_other work_items(:allgemein).update(closed: false) post :create, params: { diff --git a/test/domain/forms/multi_ordertime_test.rb b/test/domain/forms/multi_ordertime_test.rb new file mode 100644 index 000000000..59322030a --- /dev/null +++ b/test/domain/forms/multi_ordertime_test.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'test_helper' + +module Forms + class MultiOrdertimeTest < ActiveSupport::TestCase + test 'max_allowed_repetitions logic' do + # 05.01.2025 (Monday) -> 5 + form_mo = Forms::MultiOrdertime.new(work_date: Date.parse('2026-01-05')) + + assert_equal 5, form_mo.send(:max_allowed_repetitions) + + # 08.01.2025 (Thursday) -> 2 + form_th = Forms::MultiOrdertime.new(work_date: Date.parse('2026-01-08')) + + assert_equal 2, form_th.send(:max_allowed_repetitions) + + # 09.01.2025 (Friday) -> 1 + form_fr = Forms::MultiOrdertime.new(work_date: Date.parse('2026-01-09')) + + assert_equal 1, form_fr.send(:max_allowed_repetitions) + + # 10.01.2025 (Saturday) -> 1 + form_sa = Forms::MultiOrdertime.new(work_date: Date.parse('2026-01-10')) + + assert_equal 1, form_sa.send(:max_allowed_repetitions) + + # 11.01.2025 (Sunday) -> 1 + form_su = Forms::MultiOrdertime.new(work_date: Date.parse('2026-01-11')) + + assert_equal 1, form_su.send(:max_allowed_repetitions) + end + + test 'validation of repetitions' do + form = Forms::MultiOrdertime.new(work_date: Date.parse('2026-01-07'), repetitions: 4) + + assert_not_predicate form, :valid? + assert_includes form.errors[:repetitions], 'muss kleiner oder gleich 3 sein' + + form = Forms::MultiOrdertime.new(work_date: Date.parse('2026-01-07'), repetitions: 3) + + assert_predicate form, :valid? + end + end +end diff --git a/test/integration/create_ordertime_test.rb b/test/integration/create_ordertime_test.rb index 2419614a1..40d2c2952 100644 --- a/test/integration/create_ordertime_test.rb +++ b/test/integration/create_ordertime_test.rb @@ -103,6 +103,15 @@ class CreateOrdertimeTest < ActionDispatch::IntegrationTest assert_equal progressbar_color, expected_color(percentage) end + test 'creating repeated worktimes from a Wednesday on only allows 3 repetitions' do + timeout_safe do + fill_in('ordertime_work_date', with: '07.01.2026') # A Wednesday + input = find('input[name*="repetitions"]') + + assert_equal '3', input[:max] + end + end + def login login_as(:pascal) visit(new_ordertime_path)