Show overbooking warning for a user

This commit is contained in:
Klaus Zanders
2026-06-09 13:16:44 +02:00
parent d828f28c85
commit 660806aeeb
13 changed files with 434 additions and 127 deletions
@@ -1,38 +0,0 @@
<%=
component_wrapper do
flex_layout(align_items: :center, text_align: :center, p: 3) do |body|
body.with_row(mb: 2) do
render(Primer::Beta::Octicon.new(:calendar, size: :medium, color: :danger))
end
body.with_row(mb: 2) do
render(Primer::Beta::Heading.new(tag: :h2, font_size: 4)) { heading }
end
body.with_row(mb: 1) do
render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { description }
end
body.with_row do
render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { confirmation }
end
body.with_row do
form_with(
url: project_resource_allocations_path(@project),
method: :post,
id: ResourceAllocations::NewDialogComponent::FORM_ID,
data: { turbo_stream: true }
) do
safe_join(
[
hidden_field_tag("allocation_kind", @allocation_kind),
(hidden_field_tag("filters", @filters) if @filters.present?),
*@form_values.map { |name, value| hidden_field_tag("resource_allocation[#{name}]", value) }
].compact
)
end
end
end
end
%>
@@ -1,78 +0,0 @@
# 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 ResourceAllocations
module OutsideDatesStep
class FormComponent < ApplicationComponent
include ApplicationHelper
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
def initialize(allocation:, project:, allocation_kind:, form_values:, filters: nil)
super
@allocation = allocation
@project = project
@allocation_kind = allocation_kind
@form_values = form_values
@filters = filters
end
def wrapper_key
ResourceAllocations::NewDialogComponent::BODY_ID
end
private
def heading
I18n.t("resource_management.allocate_resource_dialog.outside_dates.title")
end
def description
I18n.t(
"resource_management.allocate_resource_dialog.outside_dates.description",
resource_dates: date_range(@allocation.start_date, @allocation.end_date),
work_package_dates: date_range(@allocation.entity_start_date, @allocation.entity_due_date)
)
end
def confirmation
I18n.t("resource_management.allocate_resource_dialog.outside_dates.confirm_#{@allocation.schedule_violation}")
end
def date_range(from_date, to_date)
"#{format_or_dash(from_date)} - #{format_or_dash(to_date)}"
end
def format_or_dash(date)
date.present? ? helpers.format_date(date) : ""
end
end
end
end
@@ -29,11 +29,16 @@
#++
module ResourceAllocations
module OutsideDatesStep
module WarningStep
class FooterComponent < ApplicationComponent
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
def initialize(overbooked: false)
super
@overbooked = overbooked
end
def wrapper_key
ResourceAllocations::NewDialogComponent::FOOTER_ID
end
@@ -0,0 +1,90 @@
<%=
component_wrapper do
flex_layout(align_items: :center, text_align: :center, p: 3) do |body|
if schedule_violation?
body.with_row(mb: 2) do
render(Primer::Beta::Octicon.new(:calendar, size: :medium, color: :danger))
end
body.with_row(mb: 2) do
render(Primer::Beta::Heading.new(tag: :h2, font_size: 4)) { outside_dates_heading }
end
body.with_row(mb: 1) do
render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { outside_dates_description }
end
body.with_row(mb: overbooked? ? 4 : 0) do
render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { outside_dates_confirmation }
end
end
if overbooked?
body.with_row(mb: 2) do
render(Primer::Beta::Octicon.new(:hourglass, size: :medium, color: :danger))
end
body.with_row(mb: 2) do
render(Primer::Beta::Heading.new(tag: :h2, font_size: 4)) { overbooking_heading }
end
body.with_row(mb: 3) do
render(Primer::Beta::Text.new(tag: :p, color: :muted, m: 0)) { overbooking_description }
end
@overbooked_ranges.each do |range|
body.with_row(w: :full, mb: 2) do
render(Primer::Box.new(border: true, border_radius: 2, p: 3, text_align: :left)) do
flex_layout do |box|
box.with_row(mb: 2) do
render(Primer::Beta::Text.new(font_weight: :bold)) { "#{date_range(range.start_date, range.end_date)}:" }
end
range.items.each do |item|
box.with_row(mt: 1) do
flex_layout(align_items: :center) do |row|
row.with_column(flex: 1, mr: 2) do
render(Primer::Beta::Text.new(color: candidate?(item) ? :danger : :default)) do
work_package_label(item)
end
end
row.with_column do
render(Primer::Beta::Text.new(font_weight: :bold, color: candidate?(item) ? :danger : :default)) do
format_hours(item.minutes)
end
end
if candidate?(item)
row.with_column(ml: 1) do
render(Primer::Beta::Octicon.new(:alert, color: :danger))
end
end
end
end
end
end
end
end
end
end
body.with_row(w: :full) do
form_with(
url: project_resource_allocations_path(@project),
method: :post,
id: ResourceAllocations::NewDialogComponent::FORM_ID,
data: { turbo_stream: true }
) do
safe_join(
[
hidden_field_tag("allocation_kind", @allocation_kind),
(hidden_field_tag("filters", @filters) if @filters.present?),
*@form_values.map { |name, value| hidden_field_tag("resource_allocation[#{name}]", value) }
].compact
)
end
end
end
end
%>
@@ -0,0 +1,136 @@
# 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 ResourceAllocations
module WarningStep
# Final confirmation step shown before an allocation is created. It hosts the
# "outside dates" and "overbooking" warnings; either or both may be present.
class FormComponent < ApplicationComponent
include ApplicationHelper
include OpTurbo::Streamable
include OpPrimer::ComponentHelpers
def initialize(allocation:, project:, allocation_kind:, form_values:, overbooked_ranges: [],
daily_working_minutes: nil, filters: nil)
super
@allocation = allocation
@project = project
@allocation_kind = allocation_kind
@form_values = form_values
@overbooked_ranges = overbooked_ranges
@daily_working_minutes = daily_working_minutes
@filters = filters
end
def wrapper_key
ResourceAllocations::NewDialogComponent::BODY_ID
end
def schedule_violation?
@allocation.schedule_violation.present?
end
def overbooked?
@overbooked_ranges.any?
end
private
def outside_dates_heading
t("resource_management.allocate_resource_dialog.outside_dates.title")
end
def outside_dates_description
t(
"resource_management.allocate_resource_dialog.outside_dates.description",
resource_dates: date_range(@allocation.start_date, @allocation.end_date),
work_package_dates: date_range(@allocation.entity_start_date, @allocation.entity_due_date)
)
end
def outside_dates_confirmation
t("resource_management.allocate_resource_dialog.outside_dates.confirm_#{@allocation.schedule_violation}")
end
def overbooking_heading
t("resource_management.allocate_resource_dialog.overbooking.title")
end
def overbooking_description
t(
"resource_management.allocate_resource_dialog.overbooking.description",
user: @allocation.principal.name,
hours: format_hours(@daily_working_minutes)
)
end
# The work package for each forced item, restricted to those the current
# user may see. Items for unreadable work packages render anonymously.
def work_packages_by_id
@work_packages_by_id ||=
WorkPackage
.visible(User.current)
.where(id: @overbooked_ranges.flat_map(&:work_package_ids).uniq)
.index_by(&:id)
end
def work_package_for(item)
work_packages_by_id[item.work_package_id]
end
# The label for a forced item's row. Items whose work package the user may
# not see are shown anonymously to avoid leaking subjects across projects.
def work_package_label(item)
work_package = work_package_for(item)
return t("resource_management.allocate_resource_dialog.overbooking.other_allocation") if work_package.nil?
"#{work_package.type.name} #{work_package.subject}"
end
def candidate?(item)
item.id == ResourceAllocations::Availability::CANDIDATE_ID
end
def date_range(from_date, to_date)
"#{format_or_dash(from_date)} - #{format_or_dash(to_date)}"
end
def format_or_dash(date)
date.present? ? helpers.format_date(date) : ""
end
def format_hours(minutes)
hours = minutes.to_f / 60
formatted = hours == hours.to_i ? hours.to_i : hours.round(2)
"#{formatted}h"
end
end
end
end
@@ -57,7 +57,7 @@ module ::ResourceManagement
validation = set_attributes(create_params)
return render_allocation_step(validation.result, status: :unprocessable_entity) if validation.failure?
return render_outside_dates_step(validation.result) if needs_date_confirmation?(validation.result)
return render_warning_step(validation.result) if needs_confirmation?(validation.result)
persist_allocation
end
@@ -81,17 +81,23 @@ module ::ResourceManagement
respond_with_turbo_streams(status:)
end
def render_outside_dates_step(allocation)
def render_warning_step(allocation)
ranges = overbooked_ranges(allocation)
replace_via_turbo_stream(
component: ResourceAllocations::OutsideDatesStep::FormComponent.new(
component: ResourceAllocations::WarningStep::FormComponent.new(
allocation:,
project: @project,
allocation_kind:,
form_values: submitted_allocation_params,
filters: params[:filters]
filters: params[:filters],
overbooked_ranges: ranges,
daily_working_minutes: daily_working_minutes(allocation, ranges)
)
)
replace_via_turbo_stream(component: ResourceAllocations::OutsideDatesStep::FooterComponent.new)
replace_via_turbo_stream(
component: ResourceAllocations::WarningStep::FooterComponent.new(overbooked: ranges.any?)
)
respond_with_turbo_streams
end
@@ -111,8 +117,48 @@ module ::ResourceManagement
render_allocation_step(set_attributes(create_params).result)
end
def needs_date_confirmation?(allocation)
allocation.schedule_violation && params[:confirmed].blank?
# A final confirmation step is shown when the allocation falls outside its
# work package's dates and/or would overbook the assigned user. Both
# warnings share the one step for now.
def needs_confirmation?(allocation)
return false if params[:confirmed].present?
allocation.schedule_violation.present? || overbooked_ranges(allocation).any?
end
def overbooked_ranges(allocation)
@overbooked_ranges ||= compute_overbooked_ranges(allocation)
end
# Overbooking is a user-level concern, so it is only checked for allocations
# assigned to a specific user, and only when that user has working time
# configured (otherwise their capacity is unknown, not zero).
def compute_overbooked_ranges(allocation)
return [] unless overbooking_checkable?(allocation)
availability(allocation).overbooking_with(
start_date: allocation.start_date,
end_date: allocation.end_date,
minutes: allocation.allocated_time,
work_package_id: allocation.entity_id
)
end
def overbooking_checkable?(allocation)
allocation.principal.present? &&
allocation.start_date.present? && allocation.end_date.present? &&
allocation.allocated_time.to_i.positive? &&
UserWorkingHours.for_user(allocation.principal).exists?
end
def daily_working_minutes(allocation, ranges)
return if ranges.empty?
availability(allocation).max_daily_minutes(start_date: allocation.start_date, end_date: allocation.end_date)
end
def availability(allocation)
@availability ||= ResourceAllocations::Availability.new(user: allocation.principal)
end
def set_attributes(attributes)
@@ -40,6 +40,9 @@ module ResourceAllocations
attribute :end_date, :date
attribute :work_package_ids, default: -> { [] }
attribute :over_by_minutes, :integer
# The WorkItems forced into this range, carried so a warning can list each
# work package's hours and flag the one that was just allocated.
attribute :items, default: -> { [] }
def covers?(date)
(start_date..end_date).cover?(date)
@@ -36,6 +36,10 @@ module ResourceAllocations
# projects (capacity is a user-level property). Filter-based allocations have no
# principal and are excluded.
class Availability
# Identifies the prospective allocation amongst the existing work items when
# checking a not-yet-persisted allocation.
CANDIDATE_ID = :candidate
def initialize(user:)
@user = user
end
@@ -67,10 +71,29 @@ module ResourceAllocations
# Whether a prospective allocation would still fit. `exclude_id` drops an
# existing allocation from the check (e.g. the one being edited).
def fits?(start_date:, end_date:, minutes:, exclude_id: nil)
candidate = WorkItem.new(id: :candidate, start_date:, end_date:, minutes:)
overbooking_with(start_date:, end_date:, minutes:, exclude_id:).empty?
end
# The overbooked ranges that would result from adding a prospective
# allocation. The candidate is included in the ranges' items (carrying
# `work_package_id`) so a warning can list and flag it. Empty when it fits.
# `exclude_id` drops an existing allocation from the check (e.g. the one
# being edited).
def overbooking_with(start_date:, end_date:, minutes:, work_package_id: nil, exclude_id: nil)
candidate = WorkItem.new(id: CANDIDATE_ID, start_date:, end_date:, minutes:, work_package_id:)
work_items = items.reject { |item| item.id == exclude_id } << candidate
OverbookingAnalysis.new(calendar: calendar_for(work_items), items: work_items).call.empty?
OverbookingAnalysis.new(calendar: calendar_for(work_items), items: work_items).call
end
# The user's highest single-day working capacity (in minutes) across the
# range, i.e. the most they could work on any one of those days.
def max_daily_minutes(start_date:, end_date:)
WorkingTimeCalendar
.new(user: @user, range: start_date..end_date)
.each_day
.map { |_date, minutes| minutes }
.max || 0
end
private
@@ -100,11 +100,13 @@ module ResourceAllocations
def overbooked_range(first, last, violations)
in_block = violations.select { |violation| violation.start_date >= first && violation.end_date <= last }
items = in_block.flat_map(&:items).uniq(&:id)
OverbookedRange.new(
start_date: first,
end_date: last,
work_package_ids: in_block.flat_map { |violation| violation.items.map(&:work_package_id) }.compact.uniq,
items:,
work_package_ids: items.filter_map(&:work_package_id).uniq,
over_by_minutes: in_block.map(&:over_by_minutes).max
)
end
@@ -82,6 +82,13 @@ en:
description: >-
The selected resource dates, %{resource_dates}, are outside of the work package's dates, %{work_package_dates}.
title: Do you want to allocate resources outside the work package's dates?
overbooking:
description: >-
The selected user, %{user}, would be allocated beyond their maximum hours per day during this period.
This user works %{hours} per day.
other_allocation: Other allocation
submit: Allocate overtime
title: Do you want to allocate overtime to the user?
submit: Allocate
success_message: Resource allocated.
title: Allocate resource
@@ -313,6 +313,68 @@ RSpec.describe "ResourceAllocations requests",
end.to change(ResourceAllocation, :count).by(1)
end
end
context "when the allocation would overbook the assigned user" do
shared_let(:working_assignee) do
create(:user, member_with_permissions: { project => %i[view_work_packages] }).tap do |assignee|
# Mon-Fri 8h => 480 minutes/day of capacity.
create(:user_working_hours, user: assignee, valid_from: Date.new(2025, 1, 1))
end
end
# 40h (2400 min) across Mon-Tue (960 min of capacity) overbooks the user.
let(:base_params) do
{
allocation_kind: "principal",
resource_allocation: {
principal_id: working_assignee.id,
entity_type: "WorkPackage",
entity_id: work_package.id,
start_date: "2026-03-02",
end_date: "2026-03-03",
allocated_hours: "40h"
}
}
end
it "does not create yet and renders the overbooking confirmation step" do
expect do
post project_resource_allocations_path(project), params: base_params, as: :turbo_stream
end.not_to change(ResourceAllocation, :count)
expect(response).to have_http_status(:ok)
expect(response.body).to include(I18n.t("resource_management.allocate_resource_dialog.overbooking.title"))
expect(response.body).to include('name="confirmed"')
end
it "creates the allocation once confirmed" do
expect do
post project_resource_allocations_path(project),
params: base_params.merge(confirmed: "1"),
as: :turbo_stream
end.to change(ResourceAllocation, :count).by(1)
end
end
context "when the assigned user has no working time configured" do
it "skips the overbooking check and creates directly" do
expect do
post project_resource_allocations_path(project),
params: {
allocation_kind: "principal",
resource_allocation: {
principal_id: assignee.id,
entity_type: "WorkPackage",
entity_id: work_package.id,
start_date: "2026-03-02",
end_date: "2026-03-03",
allocated_hours: "40h"
}
},
as: :turbo_stream
end.to change(ResourceAllocation, :count).by(1)
end
end
end
context "without the allocate_user_resources permission" do
@@ -127,4 +127,48 @@ RSpec.describe ResourceAllocations::Availability do
expect(availability.fits?(start_date: monday, end_date: friday, minutes: 2400, exclude_id: existing.id)).to be true
end
end
describe "#overbooking_with" do
it "is empty when the prospective allocation still fits" do
allocate(600)
ranges = availability.overbooking_with(start_date: monday, end_date: friday, minutes: 1000)
expect(ranges).to be_empty
end
it "returns the overbooked range including the candidate, flagged by its id" do
wp = create(:work_package)
allocate(1500, entity: wp)
candidate_wp = create(:work_package)
range = availability
.overbooking_with(start_date: monday, end_date: friday, minutes: 1500, work_package_id: candidate_wp.id)
.sole
expect(range.work_package_ids).to contain_exactly(wp.id, candidate_wp.id)
candidate = range.items.find { |item| item.id == described_class::CANDIDATE_ID }
expect(candidate.work_package_id).to eq(candidate_wp.id)
expect(candidate.minutes).to eq(1500)
end
it "overbooks even without existing allocations when the candidate alone exceeds capacity" do
# 2400 minutes over Mon-Tue against an 8h/day (960 min) window.
ranges = availability.overbooking_with(start_date: monday, end_date: tuesday, minutes: 2400)
expect(ranges).not_to be_empty
end
end
describe "#max_daily_minutes" do
it "returns the highest single-day capacity in the range" do
expect(availability.max_daily_minutes(start_date: monday, end_date: friday)).to eq(480)
end
it "is zero when the user has no working time configured" do
other = described_class.new(user: create(:user))
expect(other.max_daily_minutes(start_date: monday, end_date: friday)).to eq(0)
end
end
end
@@ -65,6 +65,11 @@ RSpec.describe ResourceAllocations::OverbookingAnalysis do
over_by_minutes: 1200 - 960
)
end
it "carries the forced work items so a warning can list them" do
expect(ranges.first.items.map(&:id)).to eq([7])
expect(ranges.first.items.map(&:minutes)).to eq([1200])
end
end
context "with two allocations that each fit alone but collide over a shared interval" do